From c1944b7d3b90c389089b7eb713f8dafc9e60e30f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 12:51:11 +0600 Subject: [PATCH 001/218] feat(auth): add REST API routes for login, refresh, and logout - Introduced new REST API routes for user authentication: `/auth/login`, `/auth/refresh`, and `/auth/logout`. - Updated permission callbacks for various existing routes to use more specific permission checks. - Enhanced author detail retrieval to include user metadata conditionally based on permissions. - Improved response handling in course and quiz endpoints to ensure proper data structure and access control. --- classes/RestAPI.php | 47 +- restapi/REST_Author.php | 44 +- restapi/REST_Course.php | 32 +- restapi/REST_Quiz.php | 59 +- restapi/RestAuth.php | 1347 +++++++++++++++++++++++++++++++++++---- 5 files changed, 1352 insertions(+), 177 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index 738bd02561..fe7bf3532a 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -232,6 +232,37 @@ private function loader( $class_name ) { * @return void */ public function init_routes() { + // Auth: login / refresh / logout. + register_rest_route( + $this->namespace, + '/auth/login', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_login' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + + register_rest_route( + $this->namespace, + '/auth/refresh', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_refresh' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + + register_rest_route( + $this->namespace, + '/auth/logout', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_logout' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + // Courses. register_rest_route( $this->namespace, @@ -284,7 +315,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_topics' ), ) ); @@ -305,7 +336,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) ); @@ -326,7 +357,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) ); @@ -347,7 +378,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) ); @@ -368,7 +399,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); @@ -389,7 +420,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); @@ -410,7 +441,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); @@ -473,7 +504,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) ); } diff --git a/restapi/REST_Author.php b/restapi/REST_Author.php index 5385c741af..7f7b844b8e 100644 --- a/restapi/REST_Author.php +++ b/restapi/REST_Author.php @@ -41,36 +41,40 @@ class REST_Author { * @return mixed */ public function author_detail( WP_REST_Request $request ) { - $this->user_id = $request->get_param( 'id' ); + $this->user_id = absint( $request->get_param( 'id' ) ); $user_data = get_userdata( $this->user_id ); - // Author object. - $author = is_a( $user_data, 'WP_User' ) ? $user_data->data : false; - - if ( $author ) { - // Unset user pass & key. - unset( $author->user_pass ); - unset( $author->user_activation_key ); - - // Get author course ID. - $author->courses = get_user_meta( $this->user_id, '_tutor_instructor_course_id', false ); - + if ( ! is_a( $user_data, 'WP_User' ) ) { $response = array( - 'code' => 'success', - 'message' => __( 'Author details retrieved successfully', 'tutor' ), - 'data' => $author, + 'code' => 'invalid_id', + 'message' => __( 'Author not found', 'tutor' ), + 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); + } + + $author = (object) array( + 'ID' => $user_data->ID, + 'display_name' => $user_data->display_name, + 'user_nicename' => $user_data->user_nicename, + 'courses' => get_user_meta( $this->user_id, '_tutor_instructor_course_id', false ), + ); + + if ( RestAuth::can_view_user_private_fields( $this->user_id ) ) { + $author->user_login = $user_data->user_login; + $author->user_email = $user_data->user_email; + $author->user_registered = $user_data->user_registered; + $author->user_url = $user_data->user_url; } $response = array( - 'code' => 'invalid_id', - 'message' => __( 'Author not found', 'tutor' ), - 'data' => array(), + 'code' => 'success', + 'message' => __( 'Author details retrieved successfully', 'tutor' ), + 'data' => $author, ); - return self::send( $response ); + return static::send( $response ); } } diff --git a/restapi/REST_Course.php b/restapi/REST_Course.php index ca407a49ef..c42c659a41 100644 --- a/restapi/REST_Course.php +++ b/restapi/REST_Course.php @@ -154,12 +154,22 @@ function ( $post ) { $author = get_userdata( $post->post_author ); if ( $author ) { - // Unset user pass & key. - unset( $author->data->user_pass ); - unset( $author->data->user_activation_key ); - } + $author_payload = (object) array( + 'ID' => $author->ID, + 'display_name' => $author->display_name, + 'user_nicename' => $author->user_nicename, + ); + + if ( RestAuth::can_view_user_private_fields( (int) $author->ID ) ) { + $author_payload->user_login = $author->user_login; + $author_payload->user_email = $author->user_email; + $author_payload->user_registered = $author->user_registered; + } - is_a( $author, 'WP_User' ) ? $post->post_author = $author->data : new \stdClass(); + $post->post_author = $author_payload; + } else { + $post->post_author = new \stdClass(); + } $thumbnail_size = apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ); $post->thumbnail_url = get_the_post_thumbnail_url( $post->ID, $thumbnail_size ); @@ -185,7 +195,7 @@ function ( $post ) { 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -194,7 +204,7 @@ function ( $post ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -216,7 +226,7 @@ public function course_detail( WP_REST_Request $request ) { 'message' => __( 'Course detail retrieved successfully', 'tutor' ), 'data' => $detail, ); - return self::send( $response ); + return static::send( $response ); } $response = array( 'code' => 'course_detail', @@ -224,7 +234,7 @@ public function course_detail( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -330,7 +340,7 @@ public function course_contents( WP_REST_Request $request ) { 'message' => __( 'Course contents retrieved successfully', 'tutor' ), 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -339,6 +349,6 @@ public function course_contents( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } } diff --git a/restapi/REST_Quiz.php b/restapi/REST_Quiz.php index 0293dd8c8e..f1d8e363e2 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -106,7 +106,7 @@ public function get_quiz( WP_REST_Request $request ) { 'message' => __( 'Quiz not found for given ID', 'tutor' ), 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } $quiz->quiz_settings = get_post_meta( $quiz->ID, 'tutor_quiz_option', false ); @@ -133,6 +133,10 @@ public function get_quiz( WP_REST_Request $request ) { $question->question_answers = QuizModel::get_question_answers( $question->question_id, $question->question_type ); } + if ( ! RestAuth::can_reveal_quiz_answers( $quiz_id ) ) { + $questions = static::strip_is_correct_from_questions( $questions ); + } + $quiz->quiz_questions = $questions; $response = array( @@ -141,7 +145,7 @@ public function get_quiz( WP_REST_Request $request ) { 'data' => $quiz, ); - return self::send( $response ); + return static::send( $response ); } /** @@ -188,7 +192,7 @@ public function quiz_with_settings( WP_REST_Request $request ) { 'data' => $data, ); } - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -196,7 +200,7 @@ public function quiz_with_settings( WP_REST_Request $request ) { 'message' => __( 'Quiz not found for given ID', 'tutor' ), 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } /** @@ -256,13 +260,17 @@ public function quiz_question_ans( WP_REST_Request $request ) { array_push( $data, $quiz ); } + if ( ! RestAuth::can_reveal_quiz_answers( (int) $this->post_parent ) ) { + $data = static::strip_is_correct_from_questions( $data ); + } + $response = array( 'code' => 'success', 'message' => __( 'Question retrieved successfully', 'tutor' ), 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -271,7 +279,7 @@ public function quiz_question_ans( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -312,8 +320,17 @@ public function quiz_attempt_details( WP_REST_Request $request ) { ); if ( count( $attempts ) > 0 ) { + $user_id = get_current_user_id(); + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + $can_view_all = $course_id && tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + // unserialize each attempt info. foreach ( $attempts as $key => $attempt ) { + if ( ! $can_view_all && (int) $attempt->user_id !== (int) $user_id ) { + unset( $attempts[ $key ] ); + continue; + } + $attempt->attempt_info = maybe_unserialize( $attempt->attempt_info ); // attach attempt ans. $answers = $this->get_quiz_attempt_ans( $quiz_id ); @@ -325,13 +342,15 @@ public function quiz_attempt_details( WP_REST_Request $request ) { } } + $attempts = array_values( $attempts ); + $response = array( 'code' => 'success', 'message' => __( 'Quiz attempts retrieved successfully', 'tutor' ), 'data' => $attempts, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -340,7 +359,7 @@ public function quiz_attempt_details( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -421,4 +440,28 @@ protected function answer_titles_by_id( $id ) { return $results; } + + /** + * Strip is_correct from question answer options. + * + * @since 4.0.10 + * + * @param array $questions questions with answers. + * + * @return array + */ + private static function strip_is_correct_from_questions( $questions ) { + foreach ( $questions as $question ) { + if ( empty( $question->question_answers ) || ! is_array( $question->question_answers ) ) { + continue; + } + foreach ( $question->question_answers as $answer ) { + if ( is_object( $answer ) && isset( $answer->is_correct ) ) { + unset( $answer->is_correct ); + } + } + } + + return $questions; + } } diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index f36d41bec8..018811919d 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -13,6 +13,9 @@ namespace TUTOR; use Tutor\Helpers\QueryHelper; +use Tutor\Models\EnrollmentModel; +use Tutor\Models\QuizModel; +use WP_REST_Request; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -67,6 +70,55 @@ class RestAuth { */ const KEYS_USER_META_KEY = 'tutor-api-key-secret'; + /** + * Usermeta: refresh token hashes. + * + * @var string + */ + const REFRESH_META_KEY = 'tutor_api_refresh_tokens'; + + /** + * Usermeta: access token version (invalidates JWTs). + * + * @var string + */ + const TOKEN_VERSION_META = 'tutor_api_token_version'; + + /** + * Option for JWT HMAC secret override. + * + * @var string + */ + const JWT_SECRET_OPTION = 'tutor_rest_jwt_secret'; + + /** + * Access JWT lifetime in seconds (~10 minutes). + * + * @var int + */ + const ACCESS_TTL = 600; + + /** + * Refresh token lifetime in seconds (30 days). + * + * @var int + */ + const REFRESH_TTL = 2592000; + + /** + * Max failed login attempts before rate limit. + * + * @var int + */ + const LOGIN_MAX_ATTEMPTS = 5; + + /** + * Login rate-limit window in seconds. + * + * @var int + */ + const LOGIN_WINDOW = 900; + /** * Register hooks. * @@ -79,57 +131,43 @@ public function __construct() { add_action( 'wp_ajax_tutor_update_api_permission', __CLASS__ . '::update_api_permission' ); add_action( 'wp_ajax_tutor_revoke_api_keys', __CLASS__ . '::revoke_api_keys' ); add_filter( 'determine_current_user', array( $this, 'api_auth' ) ); + add_action( 'profile_update', array( $this, 'maybe_invalidate_tokens_on_profile_update' ), 10, 2 ); + add_action( 'after_password_reset', array( $this, 'invalidate_user_tokens' ), 10, 1 ); + add_action( 'password_reset', array( $this, 'invalidate_user_tokens' ), 10, 1 ); + add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'enforce_actor_identity' ), 20, 3 ); } /** - * API auth. + * Authenticate Tutor REST requests from access JWT only. * * @since 2.7.1 - * @since 4.0.8 Only authenticate on real Tutor REST paths, and only when the - * API key permission is All (full identity must not be granted - * to Read/Write-scoped keys via determine_current_user). + * @since 4.0.8 Identity is never taken from the API key owner. * * @param int|false $user_id user id. * * @return int|false */ public function api_auth( $user_id ) { - // Don't authenticate twice. - if ( ! empty( $user_id ) || ! self::is_tutor_api_request() ) { - return $user_id; - } - - if ( ! wp_is_application_passwords_available() ) { - return $user_id; - } - - if ( ! isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { + if ( ! empty( $user_id ) || ! static::is_tutor_api_request() ) { return $user_id; } - $api_key = sanitize_key( $_SERVER['PHP_AUTH_USER'] ) ?? ''; - $api_secret = sanitize_key( $_SERVER['PHP_AUTH_PW'] ) ?? ''; - $record = self::validate_api_key_secret( $api_key, $api_secret, true ); - - if ( ! $record ) { + $token = static::get_access_token_from_request(); + if ( ! $token ) { return $user_id; } - $meta = json_decode( $record->meta_value ); - if ( ! is_object( $meta ) || ! isset( $meta->permission ) || self::ALL !== $meta->permission ) { + $jwt_user_id = static::verify_access_token( $token ); + if ( ! $jwt_user_id ) { return $user_id; } - return (int) $record->user_id; + return $jwt_user_id; } /** * Whether the current request targets a Tutor REST API route. * - * Matches the URL path only (not arbitrary query values), so embedding - * "/wp-json/tutor/" in an unrelated query parameter cannot trigger auth. - * Also accepts the plain-permalink form via the rest_route query var only. - * * @since 2.7.1 * @since 4.0.8 Path-only detection; ignore unrelated query string values. * @@ -145,7 +183,7 @@ public static function is_tutor_api_request() { if ( is_string( $path ) && '' !== $path ) { $path = trailingslashit( $path ); - $rest_prefix = trailingslashit( rest_get_url_prefix() ); // e.g. wp-json/. + $rest_prefix = trailingslashit( rest_get_url_prefix() ); $needle = '/' . $rest_prefix . 'tutor/'; if ( false !== strpos( $path, $needle ) ) { @@ -156,6 +194,39 @@ public static function is_tutor_api_request() { return false; } + /** + * Whether request is a Tutor auth login/refresh/logout route. + * + * @since 4.0.10 + * + * @return bool + */ + public static function is_auth_route() { + if ( empty( $_SERVER['REQUEST_URI'] ) ) { + return false; + } + + $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $path = wp_parse_url( $request_uri, PHP_URL_PATH ); + + if ( ! is_string( $path ) || '' === $path ) { + return false; + } + + $path = trailingslashit( $path ); + $rest_prefix = trailingslashit( rest_get_url_prefix() ); + $base = '/' . $rest_prefix . 'tutor/v1/auth/'; + + return ( + false !== strpos( $path, $base . 'login/' ) + || false !== strpos( $path, $base . 'refresh/' ) + || false !== strpos( $path, $base . 'logout/' ) + || false !== strpos( $path, $base . 'login' ) + || false !== strpos( $path, $base . 'refresh' ) + || false !== strpos( $path, $base . 'logout' ) + ); + } + /** * Generate api keys * @@ -164,10 +235,8 @@ public static function is_tutor_api_request() { * @return void send wp_json response */ public static function generate_api_keys() { - // Validate nonce. tutor_utils()->checking_nonce(); - // Check user permission. if ( ! current_user_can( 'administrator' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -187,23 +256,20 @@ public static function generate_api_keys() { ) ); - // Update user meta. $add = add_user_meta( get_current_user_id(), - self::KEYS_USER_META_KEY, + static::KEYS_USER_META_KEY, $info ); if ( $add ) { - $response = self::prepare_response( $add, $api_key, $api_secret, $permission, $description ); + $response = static::prepare_response( $add, $api_key, $api_secret, $permission, $description ); wp_send_json_success( $response ); } else { wp_send_json_error( tutor_utils()->error_message( '0' ) ); } - } - /** * Update api permission * @@ -214,10 +280,8 @@ public static function generate_api_keys() { public static function update_api_permission() { global $wpdb; - // Validate nonce. tutor_utils()->checking_nonce(); - // Check user permission. if ( ! current_user_can( 'administrator' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -232,15 +296,14 @@ public static function update_api_permission() { $meta_value->permission = $permission; $meta_value->description = $description; - // Update user meta. try { QueryHelper::update( $wpdb->usermeta, - array( 'meta_value' => json_encode( $meta_value ) ), + array( 'meta_value' => wp_json_encode( $meta_value ) ), array( 'umeta_id' => $meta_id ) ); - $response = self::prepare_response( $meta_id, $meta_value->key, $meta_value->secret, $permission, $description ); + $response = static::prepare_response( $meta_id, $meta_value->key, $meta_value->secret, $permission, $description ); wp_send_json_success( $response ); } catch ( \Throwable $th ) { @@ -256,10 +319,8 @@ public static function update_api_permission() { * @return void send wp_json response */ public static function revoke_api_keys() { - // Validate nonce. tutor_utils()->checking_nonce(); - // Check user permission. if ( ! current_user_can( 'administrator' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -270,7 +331,6 @@ public static function revoke_api_keys() { wp_send_json_error( __( 'Invalid meta id', 'tutor' ) ); } - // Delete api keys. global $wpdb; $delete = QueryHelper::delete( $wpdb->usermeta, array( 'umeta_id' => $meta_id ) ); @@ -301,14 +361,14 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r $results = QueryHelper::get_all( $table, - array( 'meta_key' => self::KEYS_USER_META_KEY ), //phpcs:ignore + array( 'meta_key' => static::KEYS_USER_META_KEY ), //phpcs:ignore 'umeta_id' ); if ( is_array( $results ) && count( $results ) ) { foreach ( $results as $result ) { $obj = json_decode( $result->meta_value ); - if ( $obj->key === $api_key && $obj->secret === $api_secret ) { + if ( is_object( $obj ) && isset( $obj->key, $obj->secret ) && $obj->key === $api_key && $obj->secret === $api_secret ) { $valid = true; if ( $return_result ) { return $result; @@ -322,118 +382,1145 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Process api request + * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. * * @since 2.2.1 + * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. * * @return boolean */ public static function process_api_request() { - $headers = apache_request_headers(); + $credentials = static::get_api_credentials_from_request(); + if ( ! $credentials ) { + return false; + } + + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! $record ) { + return false; + } + + $meta = json_decode( $record->meta_value ); + if ( ! is_object( $meta ) || empty( $meta->permission ) ) { + return false; + } + + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; + $permission = $meta->permission; + + // Auth routes may POST with a Read key (login/refresh/logout). + if ( static::is_auth_route() ) { + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + + if ( 'DELETE' === $method ) { + return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + $write_methods = array( 'POST', 'PUT', 'PATCH' ); + if ( in_array( $method, $write_methods, true ) ) { + return in_array( $permission, array( static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Whether the request has a JWT-authenticated WordPress user. + * + * @since 4.0.10 + * + * @return bool + */ + public static function has_authenticated_user() { + return (int) get_current_user_id() > 0; + } + + /** + * Valid API key for this HTTP method and an authenticated end user (JWT). + * + * Used by Tutor Pro REST routes. + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_api_request() { + return static::process_api_request() && static::has_authenticated_user(); + } + + /** + * Whether the current user may act as the given user (self or privileged admin). + * + * @since 4.0.10 + * + * @param int $target_user_id target user id. + * + * @return bool + */ + public static function can_act_as_user( $target_user_id ) { + $current = get_current_user_id(); + $target = absint( $target_user_id ); + + if ( ! $current || ! $target ) { + return false; + } + + if ( $current === $target ) { + return true; + } + + return user_can( $current, 'list_users' ) || user_can( $current, 'manage_options' ); + } + + /** + * Whether the current user may act as a student for a course. + * + * Self, admin, or instructor/admin with course content access. + * + * @since 4.0.10 + * + * @param int $student_id student user id. + * @param int $course_id course id when known. + * + * @return bool + */ + public static function can_act_as_student( $student_id, $course_id = 0 ) { + if ( static::can_act_as_user( $student_id ) ) { + return true; + } + + $current = get_current_user_id(); + $course_id = absint( $course_id ); + if ( ! $current || ! $course_id ) { + return false; + } + + return (bool) tutor_utils()->has_user_course_content_access( $current, $course_id ); + } + + /** + * Prevent client-supplied user IDs from impersonating other users. + * + * Runs for all Tutor REST routes after permission callbacks. Auth login + * routes and unauthenticated requests are skipped. Object-level checks + * can plug in via the `tutor_rest_enforce_object_access` filter. + * + * @since 4.1.0 + * + * @param mixed $response response. + * @param array $handler handler. + * @param WP_REST_Request $request request. + * + * @return mixed|\WP_Error + */ + public static function enforce_actor_identity( $response, $handler, $request ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( ! static::is_tutor_api_request() || static::is_auth_route() ) { + return $response; + } + + if ( ! static::has_authenticated_user() ) { + return $response; + } + + $author_keys = array( 'post_author', 'lesson_author', 'topic_author', 'quiz_author', 'assignment_author' ); + foreach ( $author_keys as $key ) { + if ( null === $request->get_param( $key ) || '' === $request->get_param( $key ) ) { + continue; + } + $requested = absint( $request->get_param( $key ) ); + if ( $requested && ! static::can_act_as_user( $requested ) ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this user.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } - if ( isset( $headers['Authorization'] ) ) { - $authorization_header = $headers['Authorization']; + $course_id = absint( $request->get_param( 'course_id' ) ); - if ( strpos( $authorization_header, 'Basic' ) !== false ) { - $base_64_credentials = str_replace( 'Basic ', '', $authorization_header ); - $credentials = base64_decode( $base_64_credentials ); //phpcs:ignore + if ( null !== $request->get_param( 'student_id' ) && '' !== $request->get_param( 'student_id' ) ) { + $student_id = absint( $request->get_param( 'student_id' ) ); + if ( $student_id && ! static::can_act_as_student( $student_id, $course_id ) ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this student.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } - list($api_key, $api_secret) = explode( ':', $credentials ); + // Enrollment / profile style user_id. + if ( null !== $request->get_param( 'user_id' ) && '' !== $request->get_param( 'user_id' ) ) { + $user_id = absint( $request->get_param( 'user_id' ) ); + if ( $user_id ) { + $allowed = $course_id + ? static::can_act_as_student( $user_id, $course_id ) + : static::can_act_as_user( $user_id ); - if ( self::validate_api_key_secret( $api_key, $api_secret ) ) { - return true; + if ( ! $allowed ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this user.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); } } } - // Key and secret are invalid or not provided. - return false; + /** + * Object-level access for extensions (Tutor Pro ObjectAccess). + * + * @since 4.1.0 + * + * @param true|\WP_Error $result Pass-through true, or WP_Error to deny. + * @param WP_REST_Request $request Request. + * @param array $handler Route handler. + */ + $object_access = apply_filters( 'tutor_rest_enforce_object_access', true, $request, $handler ); + if ( is_wp_error( $object_access ) ) { + return $object_access; + } + + return $response; } /** - * Prepare html response + * Permission: valid API key and may view course learning content. * - * @since 2.2.1 + * @since 4.0.10 * - * @param int $meta_id meta id. - * @param string $key api key. - * @param string $secret api secret. - * @param string $permission authorization permission. - * @param string $description description. + * @param WP_REST_Request $request request. * - * @return string + * @return bool */ - public static function prepare_response( $meta_id, $key, $secret, $permission, $description = '' ) { - $user_id = get_current_user_id(); - ob_start(); - ?> - - - display_name( $user_id ) ); ?> - - - - - - - - - - - - - - - - - - - - -
- - - - -
- - - -
- - -
- - - get_param( 'id' ) ); + if ( ! $course_id ) { + $course_id = absint( $request->get_param( 'course_id' ) ); + } + + return static::can_view_course_content( $course_id ); } /** - * Get available permission + * Permission: topics by course_id. * - * @since 2.2.1 + * @since 4.0.10 * - * @return array + * @param WP_REST_Request $request request. + * + * @return bool */ - public static function available_permissions(): array { - $permissions = array( - array( - 'value' => self::READ, - 'label' => __( 'Read', 'tutor' ), - ), - ); - return apply_filters( 'tutor_rest_api_permissions', $permissions ); + public static function permission_topics( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + return static::can_view_course_content( absint( $request->get_param( 'course_id' ) ) ); + } + + /** + * Permission: lessons or quizzes listed by topic_id. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_by_topic( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + $topic_id = absint( $request->get_param( 'topic_id' ) ); + $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); + + return static::can_view_course_content( $course_id ); + } + + /** + * Permission: quiz by quiz id. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_quiz( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + $quiz_id = absint( $request->get_param( 'id' ) ); + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + + return static::can_view_course_content( $course_id ); + } + + /** + * Whether the user may view full course learning content. + * + * @since 4.0.10 + * + * @param int $course_id course id. + * @param int $user_id user id. + * + * @return bool + */ + public static function can_view_course_content( $course_id, $user_id = 0 ) { + $course_id = absint( $course_id ); + if ( ! $course_id ) { + return false; + } + + if ( Course_List::is_public( $course_id ) ) { + return true; + } + + $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); + if ( ! $user_id ) { + return false; + } + + if ( EnrollmentModel::is_enrolled( $course_id, $user_id ) ) { + return true; + } + + return (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + } + + /** + * Whether answer keys (is_correct) may be revealed. + * + * @since 4.0.10 + * + * @param int $quiz_id quiz id. + * @param int $user_id user id. + * + * @return bool + */ + public static function can_reveal_quiz_answers( $quiz_id, $user_id = 0 ) { + $quiz_id = absint( $quiz_id ); + $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); + if ( ! $quiz_id || ! $user_id ) { + return false; + } + + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + if ( $course_id && tutor_utils()->has_user_course_content_access( $user_id, $course_id ) ) { + return true; + } + + $attempt = ( new QuizModel() )->get_quiz_attempt( $quiz_id, $user_id ); + return is_object( $attempt ) && ! empty( $attempt->attempt_ended_at ); + } + + /** + * Whether viewer may see private user fields (email, login, registered). + * + * @since 4.0.10 + * + * @param int $target_user_id target user. + * @param int $viewer_id viewer. + * + * @return bool + */ + public static function can_view_user_private_fields( $target_user_id, $viewer_id = 0 ) { + $target_user_id = absint( $target_user_id ); + $viewer_id = $viewer_id ? absint( $viewer_id ) : get_current_user_id(); + + if ( ! $target_user_id || ! $viewer_id ) { + return false; + } + + if ( $target_user_id === $viewer_id ) { + return true; + } + + if ( user_can( $viewer_id, 'list_users' ) ) { + return true; + } + + $instructor_courses = get_user_meta( $viewer_id, '_tutor_instructor_course_id', false ); + if ( ! is_array( $instructor_courses ) ) { + return false; + } + + foreach ( $instructor_courses as $course_id ) { + $course_id = absint( $course_id ); + if ( $course_id && EnrollmentModel::is_enrolled( $course_id, $target_user_id ) ) { + return true; + } + } + + return false; + } + + /** + * Login — issue access + refresh tokens. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_login( WP_REST_Request $request ) { + $ssl_error = static::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $username = sanitize_text_field( (string) $request->get_param( 'username' ) ); + $password = (string) $request->get_param( 'password' ); + + if ( '' === $username || '' === $password ) { + return new \WP_Error( + 'rest_invalid_credentials', + __( 'Invalid username or password.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + if ( is_email( $username ) ) { + $user_by_email = get_user_by( 'email', $username ); + if ( $user_by_email ) { + $username = $user_by_email->user_login; + } + } + + if ( static::is_login_rate_limited( $username ) ) { + return new \WP_Error( + 'rest_login_limited', + __( 'Too many failed login attempts. Please try again later.', 'tutor' ), + array( 'status' => 429 ) + ); + } + + $user = wp_authenticate( $username, $password ); + if ( is_wp_error( $user ) ) { + static::bump_login_rate_limit( $username ); + return new \WP_Error( + 'rest_invalid_credentials', + __( 'Invalid username or password.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + static::clear_login_rate_limit( $username ); + + return rest_ensure_response( static::build_token_response( (int) $user->ID ) ); + } + + /** + * Refresh access token (rotates refresh token). + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_refresh( WP_REST_Request $request ) { + $ssl_error = static::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); + if ( '' === $refresh ) { + return new \WP_Error( + 'rest_invalid_refresh', + __( 'Invalid refresh token.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + $user_id = static::consume_refresh_token( $refresh ); + if ( ! $user_id ) { + return new \WP_Error( + 'rest_invalid_refresh', + __( 'Invalid refresh token.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + return rest_ensure_response( static::build_token_response( $user_id ) ); + } + + /** + * Logout — delete refresh token(s). + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_logout( WP_REST_Request $request ) { + $ssl_error = static::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); + $all = (bool) $request->get_param( 'all' ); + + if ( $all ) { + $token = static::get_access_token_from_request(); + $user_id = $token ? static::verify_access_token( $token ) : 0; + if ( ! $user_id && $refresh ) { + $user_id = static::find_user_id_by_refresh_token( $refresh ); + } + if ( $user_id ) { + static::delete_all_refresh_tokens( $user_id ); + } + } elseif ( $refresh ) { + static::delete_refresh_token( $refresh ); + } + + return rest_ensure_response( + array( + 'success' => true, + ) + ); + } + + /** + * Invalidate tokens when password changes on profile update. + * + * @since 4.0.10 + * + * @param int $user_id user id. + * @param \WP_User $old_user_data old user. + * + * @return void + */ + public function maybe_invalidate_tokens_on_profile_update( $user_id, $old_user_data ) { + $user = get_userdata( $user_id ); + if ( ! $user || ! is_a( $old_user_data, 'WP_User' ) ) { + return; + } + + if ( $user->user_pass !== $old_user_data->user_pass ) { + static::invalidate_user_tokens( $user_id ); + } + } + + /** + * Bump token_version and delete refresh tokens. + * + * @since 4.0.10 + * + * @param int|\WP_User $user user id or object. + * + * @return void + */ + public static function invalidate_user_tokens( $user ) { + $user_id = is_object( $user ) ? (int) $user->ID : absint( $user ); + if ( ! $user_id ) { + return; + } + + $version = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + update_user_meta( $user_id, static::TOKEN_VERSION_META, $version + 1 ); + static::delete_all_refresh_tokens( $user_id ); + } + + /** + * Prepare html response + * + * @since 2.2.1 + * + * @param int $meta_id meta id. + * @param string $key api key. + * @param string $secret api secret. + * @param string $permission authorization permission. + * @param string $description description. + * + * @return string + */ + public static function prepare_response( $meta_id, $key, $secret, $permission, $description = '' ) { + $user_id = get_current_user_id(); + ob_start(); + ?> + + + display_name( $user_id ) ); ?> + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + +
+ + +
+ + + static::READ, + 'label' => __( 'Read', 'tutor' ), + ), + ); + return apply_filters( 'tutor_rest_api_permissions', $permissions ); + } + + /** + * Build login/refresh response payload. + * + * @param int $user_id user id. + * + * @return array + */ + private static function build_token_response( $user_id ) { + $access = static::issue_access_token( $user_id ); + $refresh = static::issue_refresh_token( $user_id ); + $user = get_userdata( $user_id ); + + return array( + 'access_token' => $access['token'], + 'expires_in' => $access['expires_in'], + 'refresh_token' => $refresh, + 'user_id' => $user_id, + 'display_name' => $user ? $user->display_name : '', + ); + } + + /** + * Issue HS256 access JWT. + * + * @param int $user_id user id. + * + * @return array{token:string,expires_in:int} + */ + private static function issue_access_token( $user_id ) { + $now = time(); + $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + + $header = static::base64url_encode( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) ); + $payload = static::base64url_encode( + wp_json_encode( + array( + 'sub' => (int) $user_id, + 'iat' => $now, + 'exp' => $now + static::ACCESS_TTL, + 'iss' => 'tutor', + 'tv' => $tv, + ) + ) + ); + $sig = static::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, static::jwt_secret(), true ) ); + + return array( + 'token' => $header . '.' . $payload . '.' . $sig, + 'expires_in' => static::ACCESS_TTL, + ); + } + + /** + * Verify access JWT. Returns user id or 0. + * + * @param string $jwt token. + * + * @return int + */ + private static function verify_access_token( $jwt ) { + $parts = explode( '.', $jwt ); + if ( 3 !== count( $parts ) ) { + return 0; + } + + list( $header_b64, $payload_b64, $sig_b64 ) = $parts; + + $expected = static::base64url_encode( + hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, static::jwt_secret(), true ) + ); + + if ( ! hash_equals( $expected, $sig_b64 ) ) { + return 0; + } + + $payload_json = static::base64url_decode( $payload_b64 ); + $payload = json_decode( $payload_json ); + if ( ! is_object( $payload ) || empty( $payload->sub ) || empty( $payload->exp ) ) { + return 0; + } + + if ( (int) $payload->exp < time() ) { + return 0; + } + + if ( empty( $payload->iss ) || 'tutor' !== $payload->iss ) { + return 0; + } + + $user_id = (int) $payload->sub; + $user = get_userdata( $user_id ); + if ( ! $user || ! $user->exists() ) { + return 0; + } + + if ( function_exists( 'is_user_spammy' ) && is_user_spammy( $user ) ) { + return 0; + } + + $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + if ( (int) ( $payload->tv ?? -1 ) !== $tv ) { + return 0; + } + + return $user_id; + } + + /** + * JWT HMAC secret. + * + * @return string + */ + private static function jwt_secret() { + $stored = get_option( static::JWT_SECRET_OPTION, '' ); + if ( is_string( $stored ) && strlen( $stored ) >= 32 ) { + return $stored; + } + + try { + $secret = bin2hex( random_bytes( 32 ) ); + } catch ( \Exception $e ) { + $secret = hash_hmac( 'sha256', 'tutor-rest-jwt', wp_salt( 'auth' ) ); + } + + update_option( static::JWT_SECRET_OPTION, $secret, false ); + return $secret; + } + + /** + * Base64 URL encode. + * + * @param string $data raw. + * + * @return string + */ + private static function base64url_encode( $data ) { + return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + + /** + * Base64 URL decode. + * + * @param string $data encoded. + * + * @return string + */ + private static function base64url_decode( $data ) { + $remainder = strlen( $data ) % 4; + if ( $remainder ) { + $data .= str_repeat( '=', 4 - $remainder ); + } + $decoded = base64_decode( strtr( $data, '-_', '+/' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + return false === $decoded ? '' : $decoded; + } + + /** + * Read access token from Authorization Bearer or X-Tutor-User-Token. + * + * @return string + */ + private static function get_access_token_from_request() { + $headers = static::get_request_headers(); + + if ( ! empty( $headers['x-tutor-user-token'] ) ) { + return trim( $headers['x-tutor-user-token'] ); + } + + if ( ! empty( $headers['authorization'] ) && 0 === stripos( $headers['authorization'], 'Bearer ' ) ) { + return trim( substr( $headers['authorization'], 7 ) ); + } + + return ''; + } + + /** + * Read API key/secret from Basic auth or X-Tutor-Api-* headers. + * + * @return array{key:string,secret:string}|null + */ + private static function get_api_credentials_from_request() { + $headers = static::get_request_headers(); + + if ( ! empty( $headers['x-tutor-api-key'] ) && ! empty( $headers['x-tutor-api-secret'] ) ) { + return array( + 'key' => sanitize_text_field( $headers['x-tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['x-tutor-api-secret'] ), + ); + } + + $auth = $headers['authorization'] ?? ''; + if ( $auth && 0 === stripos( $auth, 'Basic ' ) ) { + $decoded = base64_decode( substr( $auth, 6 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + if ( is_string( $decoded ) && false !== strpos( $decoded, ':' ) ) { + list( $key, $secret ) = explode( ':', $decoded, 2 ); + return array( + 'key' => sanitize_text_field( $key ), + 'secret' => sanitize_text_field( $secret ), + ); + } + } + + if ( isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { + return array( + 'key' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) ), + 'secret' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ), + ); + } + + return null; + } + + /** + * Normalized request headers (lowercase keys). + * + * @return array + */ + private static function get_request_headers() { + $headers = array(); + + if ( function_exists( 'apache_request_headers' ) ) { + $raw = apache_request_headers(); + if ( is_array( $raw ) ) { + foreach ( $raw as $key => $value ) { + $headers[ strtolower( $key ) ] = $value; + } + } + } + + foreach ( $_SERVER as $key => $value ) { + if ( 0 === strpos( $key, 'HTTP_' ) ) { + $header_key = strtolower( str_replace( '_', '-', substr( $key, 5 ) ) ); + $headers[ $header_key ] = wp_unslash( $value ); + } + } + + if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) && empty( $headers['authorization'] ) ) { + $headers['authorization'] = wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + } + + return $headers; + } + + /** + * Issue opaque refresh token; store hash in usermeta. + * + * @param int $user_id user id. + * + * @return string + */ + private static function issue_refresh_token( $user_id ) { + $token = bin2hex( random_bytes( 32 ) ); + $hash = hash( 'sha256', $token ); + $list = static::get_refresh_token_list( $user_id ); + $now = time(); + + $list = array_values( + array_filter( + $list, + function ( $row ) use ( $now ) { + return is_array( $row ) && ! empty( $row['hash'] ) && ! empty( $row['exp'] ) && (int) $row['exp'] > $now; + } + ) + ); + $list[] = array( + 'hash' => $hash, + 'exp' => $now + static::REFRESH_TTL, + ); + + update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); + + return $token; + } + + /** + * Validate and remove refresh token; return user id. + * + * @param string $token refresh token. + * + * @return int + */ + private static function consume_refresh_token( $token ) { + $user_id = static::find_user_id_by_refresh_token( $token ); + if ( ! $user_id ) { + return 0; + } + + static::delete_refresh_token( $token ); + return $user_id; + } + + /** + * Find user id owning a refresh token. + * + * @param string $token refresh token. + * + * @return int + */ + private static function find_user_id_by_refresh_token( $token ) { + global $wpdb; + + $hash = hash( 'sha256', $token ); + $now = time(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", + static::REFRESH_META_KEY + ) + ); + + if ( ! is_array( $rows ) ) { + return 0; + } + + foreach ( $rows as $row ) { + $list = json_decode( $row->meta_value, true ); + if ( ! is_array( $list ) ) { + continue; + } + foreach ( $list as $entry ) { + if ( empty( $entry['hash'] ) || empty( $entry['exp'] ) ) { + continue; + } + if ( hash_equals( $entry['hash'], $hash ) && (int) $entry['exp'] > $now ) { + return (int) $row->user_id; + } + } + } + + return 0; + } + + /** + * Delete one refresh token. + * + * @param string $token refresh token. + * + * @return void + */ + private static function delete_refresh_token( $token ) { + $user_id = static::find_user_id_by_refresh_token( $token ); + if ( ! $user_id ) { + // Token may already be partially matched — scan by hash after consume path. + $hash = hash( 'sha256', $token ); + static::delete_refresh_hash_for_all_users( $hash ); + return; + } + + $hash = hash( 'sha256', $token ); + $list = static::get_refresh_token_list( $user_id ); + $list = array_values( + array_filter( + $list, + function ( $row ) use ( $hash ) { + return empty( $row['hash'] ) || ! hash_equals( $row['hash'], $hash ); + } + ) + ); + update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); + } + + /** + * Remove a refresh hash across users (best-effort). + * + * @param string $hash sha256 hash. + * + * @return void + */ + private static function delete_refresh_hash_for_all_users( $hash ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT umeta_id, user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", + static::REFRESH_META_KEY + ) + ); + + if ( ! is_array( $rows ) ) { + return; + } + + foreach ( $rows as $row ) { + $list = json_decode( $row->meta_value, true ); + if ( ! is_array( $list ) ) { + continue; + } + $new = array_values( + array_filter( + $list, + function ( $entry ) use ( $hash ) { + return empty( $entry['hash'] ) || ! hash_equals( $entry['hash'], $hash ); + } + ) + ); + if ( count( $new ) !== count( $list ) ) { + update_user_meta( (int) $row->user_id, static::REFRESH_META_KEY, wp_json_encode( $new ) ); + } + } + } + + /** + * Delete all refresh tokens for a user. + * + * @param int $user_id user id. + * + * @return void + */ + private static function delete_all_refresh_tokens( $user_id ) { + delete_user_meta( $user_id, static::REFRESH_META_KEY ); + } + + /** + * Get refresh token list from usermeta. + * + * @param int $user_id user id. + * + * @return array + */ + private static function get_refresh_token_list( $user_id ) { + $raw = get_user_meta( $user_id, static::REFRESH_META_KEY, true ); + if ( empty( $raw ) ) { + return array(); + } + $list = json_decode( $raw, true ); + return is_array( $list ) ? $list : array(); + } + + /** + * Require SSL for auth endpoints (except local). + * + * @return true|\WP_Error + */ + private static function require_ssl_for_auth() { + if ( is_ssl() ) { + return true; + } + + if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) { + return true; + } + + return new \WP_Error( + 'rest_ssl_required', + __( 'HTTPS is required for authentication.', 'tutor' ), + array( 'status' => 403 ) + ); + } + + /** + * Rate-limit key for login. + * + * @param string $username username. + * + * @return string + */ + private static function login_rate_limit_key( $username ) { + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; + return 'tutor_rest_login_' . md5( strtolower( $username ) . '|' . $ip ); + } + + /** + * Whether login is rate limited. + * + * @param string $username username. + * + * @return bool + */ + private static function is_login_rate_limited( $username ) { + return (int) get_transient( static::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; + } + + /** + * Bump login failure counter. + * + * @param string $username username. + * + * @return void + */ + private static function bump_login_rate_limit( $username ) { + $key = static::login_rate_limit_key( $username ); + $count = (int) get_transient( $key ); + set_transient( $key, $count + 1, static::LOGIN_WINDOW ); + } + + /** + * Clear login rate limit on success. + * + * @param string $username username. + * + * @return void + */ + private static function clear_login_rate_limit( $username ) { + delete_transient( static::login_rate_limit_key( $username ) ); } } From fe7a1e9105bbe7f83e3e173dfd4694a7d887e34a Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 13:08:58 +0600 Subject: [PATCH 002/218] refactor(auth): enhance API key permission handling in RestAuth class - Introduced new methods for processing authenticated read, write, and delete requests, ensuring that API key permissions are validated alongside user authentication. - Refactored the existing process_api_request method to delegate permission checks to the new methods, improving code clarity and maintainability. - Updated documentation to reflect changes in permission handling and method responsibilities. --- restapi/RestAuth.php | 123 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 16 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 018811919d..cc62a21532 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -382,47 +382,105 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. + * Permission string for a valid API key/secret on this request. * - * @since 2.2.1 - * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. + * @since 4.0.10 * - * @return boolean + * @return string Empty when credentials are missing or invalid. */ - public static function process_api_request() { + private static function get_api_key_permission() { $credentials = static::get_api_credentials_from_request(); if ( ! $credentials ) { - return false; + return ''; } $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); if ( ! $record ) { - return false; + return ''; } $meta = json_decode( $record->meta_value ); if ( ! is_object( $meta ) || empty( $meta->permission ) ) { + return ''; + } + + return (string) $meta->permission; + } + + /** + * Whether the API key grants Read (or higher). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_read_request() { + $permission = static::get_api_key_permission(); + if ( '' === $permission ) { return false; } - $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; - $permission = $meta->permission; + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + /** + * Whether the API key grants Write (or higher). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_write_request() { + $permission = static::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + return in_array( $permission, array( static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Whether the API key grants Delete (or Write/All). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_delete_request() { + $permission = static::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. + * + * @since 2.2.1 + * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. + * @since 4.0.10 Delegate to process_read/write/delete_request(). + * + * @return boolean + */ + public static function process_api_request() { // Auth routes may POST with a Read key (login/refresh/logout). if ( static::is_auth_route() ) { - return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + return static::process_read_request(); } + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; + if ( 'DELETE' === $method ) { - return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + return static::process_delete_request(); } - $write_methods = array( 'POST', 'PUT', 'PATCH' ); - if ( in_array( $method, $write_methods, true ) ) { - return in_array( $permission, array( static::WRITE, static::READ_WRITE, static::ALL ), true ); + if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) { + return static::process_write_request(); } - return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + return static::process_read_request(); } /** @@ -436,10 +494,43 @@ public static function has_authenticated_user() { return (int) get_current_user_id() > 0; } + /** + * Read-capable API key and an authenticated end user (JWT). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_read_request() { + return static::process_read_request() && static::has_authenticated_user(); + } + + /** + * Write-capable API key and an authenticated end user (JWT). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_write_request() { + return static::process_write_request() && static::has_authenticated_user(); + } + + /** + * Delete-capable API key and an authenticated end user (JWT). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_delete_request() { + return static::process_delete_request() && static::has_authenticated_user(); + } + /** * Valid API key for this HTTP method and an authenticated end user (JWT). * - * Used by Tutor Pro REST routes. + * Used when the route does not declare a specific read/write/delete check. * * @since 4.0.10 * From 525e02cef590a4bc013adaaa25dd222705ff9365 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 15:53:17 +0600 Subject: [PATCH 003/218] refactor(auth): simplify permission handling for REST API routes - Updated permission callbacks for login, refresh, and logout routes to use a default true return value, streamlining access control. - Introduced new methods in RestAuth class to determine route types (login, refresh, logout) for better clarity and maintainability. - Enhanced documentation to reflect the changes in permission handling and route identification. --- classes/RestAPI.php | 4 +- restapi/RestAuth.php | 298 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 263 insertions(+), 39 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index fe7bf3532a..76caa587fe 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -249,7 +249,7 @@ public function init_routes() { array( 'methods' => 'POST', 'callback' => array( RestAuth::class, 'rest_refresh' ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => '__return_true', ) ); @@ -259,7 +259,7 @@ public function init_routes() { array( 'methods' => 'POST', 'callback' => array( RestAuth::class, 'rest_logout' ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => '__return_true', ) ); diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index cc62a21532..e826a98786 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -119,6 +119,13 @@ class RestAuth { */ const LOGIN_WINDOW = 900; + /** + * Verified access-token claims for the current request (user_id, kid). + * + * @var array{user_id:int,kid:int}|null + */ + private static $verified_token_claims = null; + /** * Register hooks. * @@ -202,6 +209,52 @@ public static function is_tutor_api_request() { * @return bool */ public static function is_auth_route() { + return static::is_login_route() || static::is_refresh_route() || static::is_logout_route(); + } + + /** + * Whether request is the auth login route (requires API key + secret). + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_login_route() { + return static::auth_path_matches( 'login' ); + } + + /** + * Whether request is the auth refresh route. + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_refresh_route() { + return static::auth_path_matches( 'refresh' ); + } + + /** + * Whether request is the auth logout route. + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_logout_route() { + return static::auth_path_matches( 'logout' ); + } + + /** + * Whether the request path matches a Tutor auth endpoint segment. + * + * @since 4.1.0 + * + * @param string $segment login|refresh|logout. + * + * @return bool + */ + private static function auth_path_matches( $segment ) { if ( empty( $_SERVER['REQUEST_URI'] ) ) { return false; } @@ -215,16 +268,9 @@ public static function is_auth_route() { $path = trailingslashit( $path ); $rest_prefix = trailingslashit( rest_get_url_prefix() ); - $base = '/' . $rest_prefix . 'tutor/v1/auth/'; - - return ( - false !== strpos( $path, $base . 'login/' ) - || false !== strpos( $path, $base . 'refresh/' ) - || false !== strpos( $path, $base . 'logout/' ) - || false !== strpos( $path, $base . 'login' ) - || false !== strpos( $path, $base . 'refresh' ) - || false !== strpos( $path, $base . 'logout' ) - ); + $base = '/' . $rest_prefix . 'tutor/v1/auth/' . $segment; + + return false !== strpos( $path, $base . '/' ) || false !== strpos( $path, $base ); } /** @@ -382,13 +428,37 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Permission string for a valid API key/secret on this request. + * Permission string for this request. + * + * Login: from API key/secret headers. + * All other Tutor REST routes: from the API key id (`kid`) bound into the access JWT. * * @since 4.0.10 + * @since 4.1.0 Non-login routes resolve permission from the access token kid. * - * @return string Empty when credentials are missing or invalid. + * @return string Empty when credentials/token are missing, invalid, or revoked. */ private static function get_api_key_permission() { + if ( static::is_login_route() ) { + return static::get_permission_from_api_credentials(); + } + + $kid = static::get_access_token_kid(); + if ( ! $kid ) { + return ''; + } + + return static::get_permission_by_kid( $kid ); + } + + /** + * Permission from API key/secret headers. + * + * @since 4.1.0 + * + * @return string + */ + private static function get_permission_from_api_credentials() { $credentials = static::get_api_credentials_from_request(); if ( ! $credentials ) { return ''; @@ -399,7 +469,44 @@ private static function get_api_key_permission() { return ''; } - $meta = json_decode( $record->meta_value ); + return static::permission_from_key_meta( $record->meta_value ); + } + + /** + * Permission for an API key usermeta row id (kid). + * + * @since 4.1.0 + * + * @param int $kid usermeta umeta_id of the API key row. + * + * @return string Empty when missing or revoked. + */ + private static function get_permission_by_kid( $kid ) { + $kid = absint( $kid ); + if ( ! $kid ) { + return ''; + } + + global $wpdb; + $record = QueryHelper::get_row( $wpdb->usermeta, array( 'umeta_id' => $kid ), 'umeta_id' ); + if ( ! $record || static::KEYS_USER_META_KEY !== $record->meta_key ) { + return ''; + } + + return static::permission_from_key_meta( $record->meta_value ); + } + + /** + * Extract permission string from API key meta JSON. + * + * @since 4.1.0 + * + * @param string $meta_value JSON meta value. + * + * @return string + */ + private static function permission_from_key_meta( $meta_value ) { + $meta = json_decode( $meta_value ); if ( ! is_object( $meta ) || empty( $meta->permission ) ) { return ''; } @@ -407,6 +514,30 @@ private static function get_api_key_permission() { return (string) $meta->permission; } + /** + * API key id (umeta_id) from the verified access token on this request. + * + * @since 4.1.0 + * + * @return int + */ + private static function get_access_token_kid() { + $token = static::get_access_token_from_request(); + if ( ! $token ) { + return 0; + } + + if ( null !== static::$verified_token_claims && isset( static::$verified_token_claims['kid'] ) ) { + return absint( static::$verified_token_claims['kid'] ); + } + + if ( ! static::verify_access_token( $token ) ) { + return 0; + } + + return isset( static::$verified_token_claims['kid'] ) ? absint( static::$verified_token_claims['kid'] ) : 0; + } + /** * Whether the API key grants Read (or higher). * @@ -456,17 +587,20 @@ public static function process_delete_request() { } /** - * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. + * Process api request — honor Read/Write/All vs HTTP method. + * + * Login uses API key/secret. All other routes use the access token's bound key permission. * * @since 2.2.1 * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. * @since 4.0.10 Delegate to process_read/write/delete_request(). + * @since 4.1.0 Login-only key/secret; other routes use JWT kid permission. * * @return boolean */ public static function process_api_request() { - // Auth routes may POST with a Read key (login/refresh/logout). - if ( static::is_auth_route() ) { + // Login may POST with a Read-capable API key. + if ( static::is_login_route() ) { return static::process_read_request(); } @@ -862,7 +996,11 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id /** * Login — issue access + refresh tokens. * + * Requires a valid Read-capable API key/secret (permission_callback). The key id + * is bound into issued tokens so later requests need only the Bearer token. + * * @since 4.0.10 + * @since 4.1.0 Bind API key id (kid) into access and refresh tokens. * * @param WP_REST_Request $request request. * @@ -874,6 +1012,33 @@ public static function rest_login( WP_REST_Request $request ) { return $ssl_error; } + $credentials = static::get_api_credentials_from_request(); + if ( ! $credentials ) { + return new \WP_Error( + 'rest_forbidden', + __( 'API key and secret are required.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! $record ) { + return new \WP_Error( + 'rest_forbidden', + __( 'Invalid API key or secret.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $kid = absint( $record->umeta_id ); + if ( ! $kid || '' === static::permission_from_key_meta( $record->meta_value ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'Invalid API key or secret.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + $username = sanitize_text_field( (string) $request->get_param( 'username' ) ); $password = (string) $request->get_param( 'password' ); @@ -912,13 +1077,14 @@ public static function rest_login( WP_REST_Request $request ) { static::clear_login_rate_limit( $username ); - return rest_ensure_response( static::build_token_response( (int) $user->ID ) ); + return rest_ensure_response( static::build_token_response( (int) $user->ID, $kid ) ); } /** * Refresh access token (rotates refresh token). * * @since 4.0.10 + * @since 4.1.0 No API key/secret; reuses kid stored with the refresh token. * * @param WP_REST_Request $request request. * @@ -939,8 +1105,8 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - $user_id = static::consume_refresh_token( $refresh ); - if ( ! $user_id ) { + $session = static::consume_refresh_token( $refresh ); + if ( ! $session ) { return new \WP_Error( 'rest_invalid_refresh', __( 'Invalid refresh token.', 'tutor' ), @@ -948,7 +1114,15 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - return rest_ensure_response( static::build_token_response( $user_id ) ); + if ( '' === static::get_permission_by_kid( $session['kid'] ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'API key has been revoked.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return rest_ensure_response( static::build_token_response( $session['user_id'], $session['kid'] ) ); } /** @@ -1121,12 +1295,13 @@ public static function available_permissions(): array { * Build login/refresh response payload. * * @param int $user_id user id. + * @param int $kid API key usermeta id. * * @return array */ - private static function build_token_response( $user_id ) { - $access = static::issue_access_token( $user_id ); - $refresh = static::issue_refresh_token( $user_id ); + private static function build_token_response( $user_id, $kid ) { + $access = static::issue_access_token( $user_id, $kid ); + $refresh = static::issue_refresh_token( $user_id, $kid ); $user = get_userdata( $user_id ); return array( @@ -1142,14 +1317,23 @@ private static function build_token_response( $user_id ) { * Issue HS256 access JWT. * * @param int $user_id user id. + * @param int $kid API key usermeta id. * * @return array{token:string,expires_in:int} */ - private static function issue_access_token( $user_id ) { + private static function issue_access_token( $user_id, $kid ) { $now = time(); $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + $kid = absint( $kid ); - $header = static::base64url_encode( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) ); + $header = static::base64url_encode( + wp_json_encode( + array( + 'alg' => 'HS256', + 'typ' => 'JWT', + ) + ) + ); $payload = static::base64url_encode( wp_json_encode( array( @@ -1158,6 +1342,7 @@ private static function issue_access_token( $user_id ) { 'exp' => $now + static::ACCESS_TTL, 'iss' => 'tutor', 'tv' => $tv, + 'kid' => $kid, ) ) ); @@ -1177,6 +1362,8 @@ private static function issue_access_token( $user_id ) { * @return int */ private static function verify_access_token( $jwt ) { + static::$verified_token_claims = null; + $parts = explode( '.', $jwt ); if ( 3 !== count( $parts ) ) { return 0; @@ -1206,6 +1393,11 @@ private static function verify_access_token( $jwt ) { return 0; } + $kid = isset( $payload->kid ) ? absint( $payload->kid ) : 0; + if ( ! $kid || '' === static::get_permission_by_kid( $kid ) ) { + return 0; + } + $user_id = (int) $payload->sub; $user = get_userdata( $user_id ); if ( ! $user || ! $user->exists() ) { @@ -1221,6 +1413,11 @@ private static function verify_access_token( $jwt ) { return 0; } + static::$verified_token_claims = array( + 'user_id' => $user_id, + 'kid' => $kid, + ); + return $user_id; } @@ -1360,17 +1557,19 @@ private static function get_request_headers() { } /** - * Issue opaque refresh token; store hash in usermeta. + * Issue opaque refresh token; store hash + kid in usermeta. * * @param int $user_id user id. + * @param int $kid API key usermeta id. * * @return string */ - private static function issue_refresh_token( $user_id ) { + private static function issue_refresh_token( $user_id, $kid ) { $token = bin2hex( random_bytes( 32 ) ); $hash = hash( 'sha256', $token ); $list = static::get_refresh_token_list( $user_id ); $now = time(); + $kid = absint( $kid ); $list = array_values( array_filter( @@ -1383,6 +1582,7 @@ function ( $row ) use ( $now ) { $list[] = array( 'hash' => $hash, 'exp' => $now + static::REFRESH_TTL, + 'kid' => $kid, ); update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); @@ -1391,20 +1591,20 @@ function ( $row ) use ( $now ) { } /** - * Validate and remove refresh token; return user id. + * Validate and remove refresh token; return user id + kid. * * @param string $token refresh token. * - * @return int + * @return array{user_id:int,kid:int}|null */ private static function consume_refresh_token( $token ) { - $user_id = static::find_user_id_by_refresh_token( $token ); - if ( ! $user_id ) { - return 0; + $session = static::find_refresh_session( $token ); + if ( ! $session ) { + return null; } static::delete_refresh_token( $token ); - return $user_id; + return $session; } /** @@ -1415,6 +1615,20 @@ private static function consume_refresh_token( $token ) { * @return int */ private static function find_user_id_by_refresh_token( $token ) { + $session = static::find_refresh_session( $token ); + return $session ? $session['user_id'] : 0; + } + + /** + * Find refresh session (user id + kid) for a refresh token. + * + * @since 4.1.0 + * + * @param string $token refresh token. + * + * @return array{user_id:int,kid:int}|null + */ + private static function find_refresh_session( $token ) { global $wpdb; $hash = hash( 'sha256', $token ); @@ -1429,7 +1643,7 @@ private static function find_user_id_by_refresh_token( $token ) { ); if ( ! is_array( $rows ) ) { - return 0; + return null; } foreach ( $rows as $row ) { @@ -1441,13 +1655,23 @@ private static function find_user_id_by_refresh_token( $token ) { if ( empty( $entry['hash'] ) || empty( $entry['exp'] ) ) { continue; } - if ( hash_equals( $entry['hash'], $hash ) && (int) $entry['exp'] > $now ) { - return (int) $row->user_id; + if ( ! hash_equals( $entry['hash'], $hash ) || (int) $entry['exp'] <= $now ) { + continue; + } + + $kid = isset( $entry['kid'] ) ? absint( $entry['kid'] ) : 0; + if ( ! $kid ) { + return null; } + + return array( + 'user_id' => (int) $row->user_id, + 'kid' => $kid, + ); } } - return 0; + return null; } /** From 8eb23c73d0752993b12ad9a04ee84da94f14caba Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 15:59:27 +0600 Subject: [PATCH 004/218] refactor(auth): update permission checks for API key management - Changed permission checks in the RestAuth class from 'administrator' to 'manage_options' for generating and revoking API keys. - This adjustment aligns permission handling with WordPress best practices, ensuring that only users with the appropriate capabilities can manage API keys. --- restapi/RestAuth.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index e826a98786..0e01f3b653 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -283,7 +283,7 @@ private static function auth_path_matches( $segment ) { public static function generate_api_keys() { tutor_utils()->checking_nonce(); - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -328,7 +328,7 @@ public static function update_api_permission() { tutor_utils()->checking_nonce(); - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -367,7 +367,7 @@ public static function update_api_permission() { public static function revoke_api_keys() { tutor_utils()->checking_nonce(); - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } From e0e17d32ac28ea309b39eff7eab67b68eb9f704a Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 15:44:31 +0600 Subject: [PATCH 005/218] refactor(auth): improve base64 encoding/decoding methods in RestAuth class - Replaced the deprecated base64_encode and base64_decode functions with sodium_bin2base64 and sodium_base642bin for enhanced security and JWT compatibility. - Updated method documentation to clarify that the encoding and decoding are now JWT-safe and do not include padding. - Added error handling for decoding to ensure robustness against invalid input. --- restapi/RestAuth.php | 145 ++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 0e01f3b653..afaa51b41b 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -159,12 +159,12 @@ public function api_auth( $user_id ) { return $user_id; } - $token = static::get_access_token_from_request(); + $token = self::get_access_token_from_request(); if ( ! $token ) { return $user_id; } - $jwt_user_id = static::verify_access_token( $token ); + $jwt_user_id = self::verify_access_token( $token ); if ( ! $jwt_user_id ) { return $user_id; } @@ -220,7 +220,7 @@ public static function is_auth_route() { * @return bool */ public static function is_login_route() { - return static::auth_path_matches( 'login' ); + return self::auth_path_matches( 'login' ); } /** @@ -231,7 +231,7 @@ public static function is_login_route() { * @return bool */ public static function is_refresh_route() { - return static::auth_path_matches( 'refresh' ); + return self::auth_path_matches( 'refresh' ); } /** @@ -242,7 +242,7 @@ public static function is_refresh_route() { * @return bool */ public static function is_logout_route() { - return static::auth_path_matches( 'logout' ); + return self::auth_path_matches( 'logout' ); } /** @@ -440,15 +440,15 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r */ private static function get_api_key_permission() { if ( static::is_login_route() ) { - return static::get_permission_from_api_credentials(); + return self::get_permission_from_api_credentials(); } - $kid = static::get_access_token_kid(); + $kid = self::get_access_token_kid(); if ( ! $kid ) { return ''; } - return static::get_permission_by_kid( $kid ); + return self::get_permission_by_kid( $kid ); } /** @@ -459,17 +459,17 @@ private static function get_api_key_permission() { * @return string */ private static function get_permission_from_api_credentials() { - $credentials = static::get_api_credentials_from_request(); + $credentials = self::get_api_credentials_from_request(); if ( ! $credentials ) { return ''; } $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); - if ( ! $record ) { + if ( ! is_object( $record ) ) { return ''; } - return static::permission_from_key_meta( $record->meta_value ); + return self::permission_from_key_meta( $record->meta_value ); } /** @@ -493,7 +493,7 @@ private static function get_permission_by_kid( $kid ) { return ''; } - return static::permission_from_key_meta( $record->meta_value ); + return self::permission_from_key_meta( $record->meta_value ); } /** @@ -522,20 +522,20 @@ private static function permission_from_key_meta( $meta_value ) { * @return int */ private static function get_access_token_kid() { - $token = static::get_access_token_from_request(); + $token = self::get_access_token_from_request(); if ( ! $token ) { return 0; } - if ( null !== static::$verified_token_claims && isset( static::$verified_token_claims['kid'] ) ) { - return absint( static::$verified_token_claims['kid'] ); + if ( null !== self::$verified_token_claims && isset( self::$verified_token_claims['kid'] ) ) { + return absint( self::$verified_token_claims['kid'] ); } - if ( ! static::verify_access_token( $token ) ) { + if ( ! self::verify_access_token( $token ) ) { return 0; } - return isset( static::$verified_token_claims['kid'] ) ? absint( static::$verified_token_claims['kid'] ) : 0; + return isset( self::$verified_token_claims['kid'] ) ? absint( self::$verified_token_claims['kid'] ) : 0; } /** @@ -546,7 +546,7 @@ private static function get_access_token_kid() { * @return bool */ public static function process_read_request() { - $permission = static::get_api_key_permission(); + $permission = self::get_api_key_permission(); if ( '' === $permission ) { return false; } @@ -562,7 +562,7 @@ public static function process_read_request() { * @return bool */ public static function process_write_request() { - $permission = static::get_api_key_permission(); + $permission = self::get_api_key_permission(); if ( '' === $permission ) { return false; } @@ -578,7 +578,7 @@ public static function process_write_request() { * @return bool */ public static function process_delete_request() { - $permission = static::get_api_key_permission(); + $permission = self::get_api_key_permission(); if ( '' === $permission ) { return false; } @@ -1007,12 +1007,12 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id * @return \WP_REST_Response|\WP_Error */ public static function rest_login( WP_REST_Request $request ) { - $ssl_error = static::require_ssl_for_auth(); + $ssl_error = self::require_ssl_for_auth(); if ( is_wp_error( $ssl_error ) ) { return $ssl_error; } - $credentials = static::get_api_credentials_from_request(); + $credentials = self::get_api_credentials_from_request(); if ( ! $credentials ) { return new \WP_Error( 'rest_forbidden', @@ -1022,7 +1022,7 @@ public static function rest_login( WP_REST_Request $request ) { } $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); - if ( ! $record ) { + if ( ! is_object( $record ) ) { return new \WP_Error( 'rest_forbidden', __( 'Invalid API key or secret.', 'tutor' ), @@ -1031,7 +1031,7 @@ public static function rest_login( WP_REST_Request $request ) { } $kid = absint( $record->umeta_id ); - if ( ! $kid || '' === static::permission_from_key_meta( $record->meta_value ) ) { + if ( ! $kid || '' === self::permission_from_key_meta( $record->meta_value ) ) { return new \WP_Error( 'rest_forbidden', __( 'Invalid API key or secret.', 'tutor' ), @@ -1057,7 +1057,7 @@ public static function rest_login( WP_REST_Request $request ) { } } - if ( static::is_login_rate_limited( $username ) ) { + if ( self::is_login_rate_limited( $username ) ) { return new \WP_Error( 'rest_login_limited', __( 'Too many failed login attempts. Please try again later.', 'tutor' ), @@ -1067,7 +1067,7 @@ public static function rest_login( WP_REST_Request $request ) { $user = wp_authenticate( $username, $password ); if ( is_wp_error( $user ) ) { - static::bump_login_rate_limit( $username ); + self::bump_login_rate_limit( $username ); return new \WP_Error( 'rest_invalid_credentials', __( 'Invalid username or password.', 'tutor' ), @@ -1075,9 +1075,9 @@ public static function rest_login( WP_REST_Request $request ) { ); } - static::clear_login_rate_limit( $username ); + self::clear_login_rate_limit( $username ); - return rest_ensure_response( static::build_token_response( (int) $user->ID, $kid ) ); + return rest_ensure_response( self::build_token_response( (int) $user->ID, $kid ) ); } /** @@ -1091,7 +1091,7 @@ public static function rest_login( WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public static function rest_refresh( WP_REST_Request $request ) { - $ssl_error = static::require_ssl_for_auth(); + $ssl_error = self::require_ssl_for_auth(); if ( is_wp_error( $ssl_error ) ) { return $ssl_error; } @@ -1105,7 +1105,7 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - $session = static::consume_refresh_token( $refresh ); + $session = self::consume_refresh_token( $refresh ); if ( ! $session ) { return new \WP_Error( 'rest_invalid_refresh', @@ -1114,7 +1114,7 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - if ( '' === static::get_permission_by_kid( $session['kid'] ) ) { + if ( '' === self::get_permission_by_kid( $session['kid'] ) ) { return new \WP_Error( 'rest_forbidden', __( 'API key has been revoked.', 'tutor' ), @@ -1122,7 +1122,7 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - return rest_ensure_response( static::build_token_response( $session['user_id'], $session['kid'] ) ); + return rest_ensure_response( self::build_token_response( $session['user_id'], $session['kid'] ) ); } /** @@ -1135,7 +1135,7 @@ public static function rest_refresh( WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public static function rest_logout( WP_REST_Request $request ) { - $ssl_error = static::require_ssl_for_auth(); + $ssl_error = self::require_ssl_for_auth(); if ( is_wp_error( $ssl_error ) ) { return $ssl_error; } @@ -1144,16 +1144,16 @@ public static function rest_logout( WP_REST_Request $request ) { $all = (bool) $request->get_param( 'all' ); if ( $all ) { - $token = static::get_access_token_from_request(); - $user_id = $token ? static::verify_access_token( $token ) : 0; + $token = self::get_access_token_from_request(); + $user_id = $token ? self::verify_access_token( $token ) : 0; if ( ! $user_id && $refresh ) { - $user_id = static::find_user_id_by_refresh_token( $refresh ); + $user_id = self::find_user_id_by_refresh_token( $refresh ); } if ( $user_id ) { - static::delete_all_refresh_tokens( $user_id ); + self::delete_all_refresh_tokens( $user_id ); } } elseif ( $refresh ) { - static::delete_refresh_token( $refresh ); + self::delete_refresh_token( $refresh ); } return rest_ensure_response( @@ -1201,7 +1201,7 @@ public static function invalidate_user_tokens( $user ) { $version = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); update_user_meta( $user_id, static::TOKEN_VERSION_META, $version + 1 ); - static::delete_all_refresh_tokens( $user_id ); + self::delete_all_refresh_tokens( $user_id ); } /** @@ -1300,8 +1300,8 @@ public static function available_permissions(): array { * @return array */ private static function build_token_response( $user_id, $kid ) { - $access = static::issue_access_token( $user_id, $kid ); - $refresh = static::issue_refresh_token( $user_id, $kid ); + $access = self::issue_access_token( $user_id, $kid ); + $refresh = self::issue_refresh_token( $user_id, $kid ); $user = get_userdata( $user_id ); return array( @@ -1326,7 +1326,7 @@ private static function issue_access_token( $user_id, $kid ) { $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); $kid = absint( $kid ); - $header = static::base64url_encode( + $header = self::base64url_encode( wp_json_encode( array( 'alg' => 'HS256', @@ -1334,7 +1334,7 @@ private static function issue_access_token( $user_id, $kid ) { ) ) ); - $payload = static::base64url_encode( + $payload = self::base64url_encode( wp_json_encode( array( 'sub' => (int) $user_id, @@ -1346,7 +1346,7 @@ private static function issue_access_token( $user_id, $kid ) { ) ) ); - $sig = static::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, static::jwt_secret(), true ) ); + $sig = self::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, self::jwt_secret(), true ) ); return array( 'token' => $header . '.' . $payload . '.' . $sig, @@ -1362,7 +1362,7 @@ private static function issue_access_token( $user_id, $kid ) { * @return int */ private static function verify_access_token( $jwt ) { - static::$verified_token_claims = null; + self::$verified_token_claims = null; $parts = explode( '.', $jwt ); if ( 3 !== count( $parts ) ) { @@ -1371,15 +1371,15 @@ private static function verify_access_token( $jwt ) { list( $header_b64, $payload_b64, $sig_b64 ) = $parts; - $expected = static::base64url_encode( - hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, static::jwt_secret(), true ) + $expected = self::base64url_encode( + hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, self::jwt_secret(), true ) ); if ( ! hash_equals( $expected, $sig_b64 ) ) { return 0; } - $payload_json = static::base64url_decode( $payload_b64 ); + $payload_json = self::base64url_decode( $payload_b64 ); $payload = json_decode( $payload_json ); if ( ! is_object( $payload ) || empty( $payload->sub ) || empty( $payload->exp ) ) { return 0; @@ -1394,7 +1394,7 @@ private static function verify_access_token( $jwt ) { } $kid = isset( $payload->kid ) ? absint( $payload->kid ) : 0; - if ( ! $kid || '' === static::get_permission_by_kid( $kid ) ) { + if ( ! $kid || '' === self::get_permission_by_kid( $kid ) ) { return 0; } @@ -1413,7 +1413,7 @@ private static function verify_access_token( $jwt ) { return 0; } - static::$verified_token_claims = array( + self::$verified_token_claims = array( 'user_id' => $user_id, 'kid' => $kid, ); @@ -1443,30 +1443,29 @@ private static function jwt_secret() { } /** - * Base64 URL encode. + * Base64 URL encode (JWT-safe, no padding). * * @param string $data raw. * * @return string */ private static function base64url_encode( $data ) { - return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return sodium_bin2base64( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); } /** - * Base64 URL decode. + * Base64 URL decode (JWT-safe, no padding). * * @param string $data encoded. * * @return string */ private static function base64url_decode( $data ) { - $remainder = strlen( $data ) % 4; - if ( $remainder ) { - $data .= str_repeat( '=', 4 - $remainder ); + try { + return sodium_base642bin( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); + } catch ( \Throwable $e ) { + return ''; } - $decoded = base64_decode( strtr( $data, '-_', '+/' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - return false === $decoded ? '' : $decoded; } /** @@ -1475,7 +1474,7 @@ private static function base64url_decode( $data ) { * @return string */ private static function get_access_token_from_request() { - $headers = static::get_request_headers(); + $headers = self::get_request_headers(); if ( ! empty( $headers['x-tutor-user-token'] ) ) { return trim( $headers['x-tutor-user-token'] ); @@ -1494,7 +1493,7 @@ private static function get_access_token_from_request() { * @return array{key:string,secret:string}|null */ private static function get_api_credentials_from_request() { - $headers = static::get_request_headers(); + $headers = self::get_request_headers(); if ( ! empty( $headers['x-tutor-api-key'] ) && ! empty( $headers['x-tutor-api-secret'] ) ) { return array( @@ -1505,7 +1504,11 @@ private static function get_api_credentials_from_request() { $auth = $headers['authorization'] ?? ''; if ( $auth && 0 === stripos( $auth, 'Basic ' ) ) { - $decoded = base64_decode( substr( $auth, 6 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + try { + $decoded = sodium_base642bin( substr( $auth, 6 ), SODIUM_BASE64_VARIANT_ORIGINAL ); + } catch ( \Throwable $e ) { + $decoded = ''; + } if ( is_string( $decoded ) && false !== strpos( $decoded, ':' ) ) { list( $key, $secret ) = explode( ':', $decoded, 2 ); return array( @@ -1567,7 +1570,7 @@ private static function get_request_headers() { private static function issue_refresh_token( $user_id, $kid ) { $token = bin2hex( random_bytes( 32 ) ); $hash = hash( 'sha256', $token ); - $list = static::get_refresh_token_list( $user_id ); + $list = self::get_refresh_token_list( $user_id ); $now = time(); $kid = absint( $kid ); @@ -1598,12 +1601,12 @@ function ( $row ) use ( $now ) { * @return array{user_id:int,kid:int}|null */ private static function consume_refresh_token( $token ) { - $session = static::find_refresh_session( $token ); + $session = self::find_refresh_session( $token ); if ( ! $session ) { return null; } - static::delete_refresh_token( $token ); + self::delete_refresh_token( $token ); return $session; } @@ -1615,7 +1618,7 @@ private static function consume_refresh_token( $token ) { * @return int */ private static function find_user_id_by_refresh_token( $token ) { - $session = static::find_refresh_session( $token ); + $session = self::find_refresh_session( $token ); return $session ? $session['user_id'] : 0; } @@ -1682,16 +1685,16 @@ private static function find_refresh_session( $token ) { * @return void */ private static function delete_refresh_token( $token ) { - $user_id = static::find_user_id_by_refresh_token( $token ); + $user_id = self::find_user_id_by_refresh_token( $token ); if ( ! $user_id ) { // Token may already be partially matched — scan by hash after consume path. $hash = hash( 'sha256', $token ); - static::delete_refresh_hash_for_all_users( $hash ); + self::delete_refresh_hash_for_all_users( $hash ); return; } $hash = hash( 'sha256', $token ); - $list = static::get_refresh_token_list( $user_id ); + $list = self::get_refresh_token_list( $user_id ); $list = array_values( array_filter( $list, @@ -1812,7 +1815,7 @@ private static function login_rate_limit_key( $username ) { * @return bool */ private static function is_login_rate_limited( $username ) { - return (int) get_transient( static::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; + return (int) get_transient( self::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; } /** @@ -1823,7 +1826,7 @@ private static function is_login_rate_limited( $username ) { * @return void */ private static function bump_login_rate_limit( $username ) { - $key = static::login_rate_limit_key( $username ); + $key = self::login_rate_limit_key( $username ); $count = (int) get_transient( $key ); set_transient( $key, $count + 1, static::LOGIN_WINDOW ); } @@ -1836,6 +1839,6 @@ private static function bump_login_rate_limit( $username ) { * @return void */ private static function clear_login_rate_limit( $username ) { - delete_transient( static::login_rate_limit_key( $username ) ); + delete_transient( self::login_rate_limit_key( $username ) ); } } From 6945808e741e949cd84dfa8b6f35ab7682a5191c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 15:54:34 +0600 Subject: [PATCH 006/218] refactor(auth): update header naming conventions in RestAuth class - Changed header names from 'X-Tutor-Api-Key' and 'X-Tutor-User-Token' to 'Tutor-Api-Key' and 'Tutor-User-Token' for consistency and clarity. - Updated method documentation to reflect the new header names, ensuring accurate API usage guidance. --- restapi/RestAuth.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index afaa51b41b..a36b667a04 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -592,7 +592,7 @@ public static function process_delete_request() { * Login uses API key/secret. All other routes use the access token's bound key permission. * * @since 2.2.1 - * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. + * @since 4.0.10 Honor key permission; accept Tutor-Api-Key headers. * @since 4.0.10 Delegate to process_read/write/delete_request(). * @since 4.1.0 Login-only key/secret; other routes use JWT kid permission. * @@ -1469,15 +1469,15 @@ private static function base64url_decode( $data ) { } /** - * Read access token from Authorization Bearer or X-Tutor-User-Token. + * Read access token from Authorization Bearer or Tutor-User-Token. * * @return string */ private static function get_access_token_from_request() { $headers = self::get_request_headers(); - if ( ! empty( $headers['x-tutor-user-token'] ) ) { - return trim( $headers['x-tutor-user-token'] ); + if ( ! empty( $headers['tutor-user-token'] ) ) { + return trim( $headers['tutor-user-token'] ); } if ( ! empty( $headers['authorization'] ) && 0 === stripos( $headers['authorization'], 'Bearer ' ) ) { @@ -1488,17 +1488,17 @@ private static function get_access_token_from_request() { } /** - * Read API key/secret from Basic auth or X-Tutor-Api-* headers. + * Read API key/secret from Basic auth or Tutor-Api-* headers. * * @return array{key:string,secret:string}|null */ private static function get_api_credentials_from_request() { $headers = self::get_request_headers(); - if ( ! empty( $headers['x-tutor-api-key'] ) && ! empty( $headers['x-tutor-api-secret'] ) ) { + if ( ! empty( $headers['tutor-api-key'] ) && ! empty( $headers['tutor-api-secret'] ) ) { return array( - 'key' => sanitize_text_field( $headers['x-tutor-api-key'] ), - 'secret' => sanitize_text_field( $headers['x-tutor-api-secret'] ), + 'key' => sanitize_text_field( $headers['tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), ); } From b8a6e455452bb03e6e51684301ba72441cf27592 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 16:01:25 +0600 Subject: [PATCH 007/218] refactor(auth): streamline API key retrieval in RestAuth class - Simplified the get_api_credentials_from_request method to exclusively read API key and secret from Tutor-Api-* headers, removing support for Basic auth and PHP_AUTH_USER/PW. - Updated method documentation to reflect the changes in credential retrieval, ensuring clarity for API users. --- restapi/RestAuth.php | 37 +++++++------------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index a36b667a04..2775943320 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1488,44 +1488,21 @@ private static function get_access_token_from_request() { } /** - * Read API key/secret from Basic auth or Tutor-Api-* headers. + * Read API key/secret from Tutor-Api-* headers. * * @return array{key:string,secret:string}|null */ private static function get_api_credentials_from_request() { $headers = self::get_request_headers(); - if ( ! empty( $headers['tutor-api-key'] ) && ! empty( $headers['tutor-api-secret'] ) ) { - return array( - 'key' => sanitize_text_field( $headers['tutor-api-key'] ), - 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), - ); - } - - $auth = $headers['authorization'] ?? ''; - if ( $auth && 0 === stripos( $auth, 'Basic ' ) ) { - try { - $decoded = sodium_base642bin( substr( $auth, 6 ), SODIUM_BASE64_VARIANT_ORIGINAL ); - } catch ( \Throwable $e ) { - $decoded = ''; - } - if ( is_string( $decoded ) && false !== strpos( $decoded, ':' ) ) { - list( $key, $secret ) = explode( ':', $decoded, 2 ); - return array( - 'key' => sanitize_text_field( $key ), - 'secret' => sanitize_text_field( $secret ), - ); - } - } - - if ( isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { - return array( - 'key' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) ), - 'secret' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ), - ); + if ( empty( $headers['tutor-api-key'] ) || empty( $headers['tutor-api-secret'] ) ) { + return null; } - return null; + return array( + 'key' => sanitize_text_field( $headers['tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), + ); } /** From 33b84d034944c07106c700e04e1a9cf8b2e44b30 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 16:08:15 +0600 Subject: [PATCH 008/218] fix(auth): sanitize authorization header in RestAuth class - Updated the handling of the REDIRECT_HTTP_AUTHORIZATION server variable to sanitize its value using sanitize_text_field before assigning it to the headers array. - This change enhances security by ensuring that the authorization header is properly sanitized, preventing potential security vulnerabilities. --- restapi/RestAuth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 2775943320..17175d3a2e 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1530,7 +1530,7 @@ private static function get_request_headers() { } if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) && empty( $headers['authorization'] ) ) { - $headers['authorization'] = wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $headers['authorization'] = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); } return $headers; From 7c2f0926f508b459de88a368116e751bafda2141 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 28 Sep 2026 13:27:13 +0600 Subject: [PATCH 009/218] fix(restapi): enhance quiz attempt details security and accuracy - Updated the `quiz_attempt_details` method to sanitize the quiz ID parameter using the `Input::sanitize` method, improving security against potential injection attacks. - Modified the `get_quiz_attempt_ans` method to accept the attempt ID instead of the quiz ID, ensuring that answers are fetched based on the specific attempt, preventing cross-user answer leaks. - Enhanced method documentation to reflect the changes and clarify the purpose of the parameters. --- restapi/REST_Quiz.php | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/restapi/REST_Quiz.php b/restapi/REST_Quiz.php index f1d8e363e2..0de9e4efc9 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -134,7 +134,7 @@ public function get_quiz( WP_REST_Request $request ) { } if ( ! RestAuth::can_reveal_quiz_answers( $quiz_id ) ) { - $questions = static::strip_is_correct_from_questions( $questions ); + $questions = self::strip_is_correct_from_questions( $questions ); } $quiz->quiz_questions = $questions; @@ -185,13 +185,13 @@ public function quiz_with_settings( WP_REST_Request $request ) { $quiz->quiz_settings = get_post_meta( $quiz->ID, 'tutor_quiz_option', false ); array_push( $data, $quiz ); - - $response = array( - 'code' => 'success', - 'message' => __( 'Quiz retrieved successfully', 'tutor' ), - 'data' => $data, - ); } + + $response = array( + 'code' => 'success', + 'message' => __( 'Quiz retrieved successfully', 'tutor' ), + 'data' => $data, + ); return static::send( $response ); } @@ -261,7 +261,7 @@ public function quiz_question_ans( WP_REST_Request $request ) { } if ( ! RestAuth::can_reveal_quiz_answers( (int) $this->post_parent ) ) { - $data = static::strip_is_correct_from_questions( $data ); + $data = self::strip_is_correct_from_questions( $data ); } $response = array( @@ -294,13 +294,14 @@ public function quiz_question_ans( WP_REST_Request $request ) { public function quiz_attempt_details( WP_REST_Request $request ) { global $wpdb; - $quiz_id = $request->get_param( 'id' ); + $quiz_id = Input::sanitize( $request->get_param( 'id' ), 0, Input::TYPE_INT ); $wpdb->quiz_attempt = $wpdb->prefix . $this->t_quiz_attempt; $attempts = $wpdb->get_results( $wpdb->prepare( "SELECT + att.attempt_id, att.user_id, att.total_questions, att.total_answered_questions, @@ -332,8 +333,8 @@ public function quiz_attempt_details( WP_REST_Request $request ) { } $attempt->attempt_info = maybe_unserialize( $attempt->attempt_info ); - // attach attempt ans. - $answers = $this->get_quiz_attempt_ans( $quiz_id ); + // Attach answers for this attempt only. + $answers = $this->get_quiz_attempt_ans( (int) $attempt->attempt_id ); if ( false !== $answers ) { $attempt->attempts_answer = $answers; @@ -363,16 +364,23 @@ public function quiz_attempt_details( WP_REST_Request $request ) { } /** - * Get quiz attempt answers. + * Get quiz attempt answers for a single attempt. * * @since 1.7.1 + * @since 4.0.10 Scope by quiz_attempt_id to prevent cross-user answer leaks. * - * @param int $quiz_id quiz id. + * @param int $attempt_id quiz attempt id. * * @return mixed */ - protected function get_quiz_attempt_ans( $quiz_id ) { + protected function get_quiz_attempt_ans( $attempt_id ) { global $wpdb; + + $attempt_id = absint( $attempt_id ); + if ( ! $attempt_id ) { + return false; + } + $wpdb->quiz_attempt_ans = $wpdb->prefix . $this->t_quiz_attempt_ans; $wpdb->quiz_question = $wpdb->prefix . $this->t_quiz_question; @@ -387,9 +395,9 @@ protected function get_quiz_attempt_ans( $quiz_id ) { att_ans.minus_mark, att_ans.is_correct FROM {$wpdb->quiz_attempt_ans} as att_ans JOIN {$wpdb->quiz_question} q ON q.question_id = att_ans.question_id - WHERE att_ans.quiz_id = %d + WHERE att_ans.quiz_attempt_id = %d ", - $quiz_id + $attempt_id ) ); From bcf5ce6456fc327b0d40b6fcfc28acbfb3f91179 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 28 Sep 2026 14:04:51 +0600 Subject: [PATCH 010/218] refactor(auth): remove login rate limiting from RestAuth class - Eliminated the login rate limit functionality, including constants and methods related to tracking failed login attempts. - Updated the login authentication process to remove rate limiting checks, simplifying the login flow. - Adjusted method documentation to reflect the removal of rate limiting features, ensuring clarity for future development. --- restapi/RestAuth.php | 72 -------------------------------------------- 1 file changed, 72 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 17175d3a2e..24550e2b4f 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -105,20 +105,6 @@ class RestAuth { */ const REFRESH_TTL = 2592000; - /** - * Max failed login attempts before rate limit. - * - * @var int - */ - const LOGIN_MAX_ATTEMPTS = 5; - - /** - * Login rate-limit window in seconds. - * - * @var int - */ - const LOGIN_WINDOW = 900; - /** * Verified access-token claims for the current request (user_id, kid). * @@ -1057,17 +1043,8 @@ public static function rest_login( WP_REST_Request $request ) { } } - if ( self::is_login_rate_limited( $username ) ) { - return new \WP_Error( - 'rest_login_limited', - __( 'Too many failed login attempts. Please try again later.', 'tutor' ), - array( 'status' => 429 ) - ); - } - $user = wp_authenticate( $username, $password ); if ( is_wp_error( $user ) ) { - self::bump_login_rate_limit( $username ); return new \WP_Error( 'rest_invalid_credentials', __( 'Invalid username or password.', 'tutor' ), @@ -1075,8 +1052,6 @@ public static function rest_login( WP_REST_Request $request ) { ); } - self::clear_login_rate_limit( $username ); - return rest_ensure_response( self::build_token_response( (int) $user->ID, $kid ) ); } @@ -1771,51 +1746,4 @@ private static function require_ssl_for_auth() { array( 'status' => 403 ) ); } - - /** - * Rate-limit key for login. - * - * @param string $username username. - * - * @return string - */ - private static function login_rate_limit_key( $username ) { - $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; - return 'tutor_rest_login_' . md5( strtolower( $username ) . '|' . $ip ); - } - - /** - * Whether login is rate limited. - * - * @param string $username username. - * - * @return bool - */ - private static function is_login_rate_limited( $username ) { - return (int) get_transient( self::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; - } - - /** - * Bump login failure counter. - * - * @param string $username username. - * - * @return void - */ - private static function bump_login_rate_limit( $username ) { - $key = self::login_rate_limit_key( $username ); - $count = (int) get_transient( $key ); - set_transient( $key, $count + 1, static::LOGIN_WINDOW ); - } - - /** - * Clear login rate limit on success. - * - * @param string $username username. - * - * @return void - */ - private static function clear_login_rate_limit( $username ) { - delete_transient( self::login_rate_limit_key( $username ) ); - } } From 0d975c62132fab8bd77410532ac038c44fc55c3f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 28 Sep 2026 15:47:29 +0600 Subject: [PATCH 011/218] feat(api): implement token lifetime settings management in REST API - Added functionality to manage access and refresh token lifetimes through a new settings form in the admin dashboard. - Introduced new constants and methods in the RestAuth class to handle token lifetime configurations, including validation for minimum and maximum values. - Updated the manage-api-keys.js script to handle form submissions for saving token settings with appropriate user feedback. - Enhanced the manage-tokens.php view to display and allow editing of token lifetime settings. - Improved overall documentation for new methods and constants related to token management. --- .../segments/manage-api-keys.js | 33 +++- .../v2/components/_form-elements.scss | 8 + restapi/RestAuth.php | 155 +++++++++++++++++- views/pages/tools/manage-tokens.php | 83 ++++++++++ 4 files changed, 270 insertions(+), 9 deletions(-) diff --git a/assets/src/js/admin-dashboard/segments/manage-api-keys.js b/assets/src/js/admin-dashboard/segments/manage-api-keys.js index 414f983764..8fe9f08d32 100644 --- a/assets/src/js/admin-dashboard/segments/manage-api-keys.js +++ b/assets/src/js/admin-dashboard/segments/manage-api-keys.js @@ -10,15 +10,46 @@ document.addEventListener("DOMContentLoaded", async function() { const submitBtn = document.querySelector("#tutor-generate-api-keys button[type=submit]"); const modal = document.getElementById("tutor-add-new-api-keys"); const noRecordElem = document.getElementById("tutor-api-keys-no-record"); + const tokenSettingsForm = document.getElementById("tutor-rest-api-token-settings"); + const tokenSettingsSubmitBtn = document.querySelector("#tutor-rest-api-token-settings button[type=submit]"); const updatePermissionForm = document.querySelector("#tutor-update-permission-form"); const updateSubmitBtn = document.querySelector("#tutor-update-permission-modal button[type=submit]"); const updatePermissionModal = document.querySelector("#tutor-update-permission-modal"); - if (!keysListWrapper) { + if (!keysListWrapper && !tokenSettingsForm) { return; } + // Save token lifetime settings + if (tokenSettingsForm) { + tokenSettingsForm.onsubmit = async (e) => { + e.preventDefault(); + + const formData = new FormData(tokenSettingsForm); + + try { + tokenSettingsSubmitBtn.classList.add("is-loading"); + tokenSettingsSubmitBtn.setAttribute("disabled", true); + + const post = await ajaxHandler(formData); + const res = await post.json(); + const { success, data } = res; + + if (success) { + tutor_toast(__("Success", "tutor"), data, "success"); + } else { + tutor_toast(__("Failed", "tutor"), data, "error"); + } + } catch (error) { + tutor_toast(__("Failed", "tutor"), defaultErrMsg, "error"); + } finally { + tokenSettingsSubmitBtn.classList.remove("is-loading"); + tokenSettingsSubmitBtn.removeAttribute("disabled"); + } + }; + } + // Add API keys if (apiKeysForm) { apiKeysForm.onsubmit = async (e) => { diff --git a/assets/src/scss/admin-dashboard/v2/components/_form-elements.scss b/assets/src/scss/admin-dashboard/v2/components/_form-elements.scss index fc1f9338ed..31203ac444 100644 --- a/assets/src/scss/admin-dashboard/v2/components/_form-elements.scss +++ b/assets/src/scss/admin-dashboard/v2/components/_form-elements.scss @@ -20,6 +20,14 @@ min-width: 90px; height: 40px; } + + &.tutor-w-160 { + padding: 0px 5px; + text-align: center; + max-width: 160px; + min-width: 160px; + height: 40px; + } } textarea.tutor-form-control { diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 24550e2b4f..b39971cbff 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -92,19 +92,73 @@ class RestAuth { const JWT_SECRET_OPTION = 'tutor_rest_jwt_secret'; /** - * Access JWT lifetime in seconds (~10 minutes). + * Default access JWT lifetime in seconds (~10 minutes). * * @var int */ const ACCESS_TTL = 600; /** - * Refresh token lifetime in seconds (30 days). + * Default refresh token lifetime in seconds (30 days). * * @var int */ const REFRESH_TTL = 2592000; + /** + * Option key for access JWT lifetime (seconds). + * + * @since 4.1.0 + * + * @var string + */ + const OPTION_ACCESS_TTL = 'rest_api_access_token_ttl'; + + /** + * Option key for refresh token lifetime (seconds). + * + * @since 4.1.0 + * + * @var string + */ + const OPTION_REFRESH_TTL = 'rest_api_refresh_token_ttl'; + + /** + * Minimum access token TTL in seconds. + * + * @since 4.1.0 + * + * @var int + */ + const MIN_ACCESS_TTL = 60; + + /** + * Maximum access token TTL in seconds (24 hours). + * + * @since 4.1.0 + * + * @var int + */ + const MAX_ACCESS_TTL = 86400; + + /** + * Minimum refresh token TTL in seconds (1 hour). + * + * @since 4.1.0 + * + * @var int + */ + const MIN_REFRESH_TTL = 3600; + + /** + * Maximum refresh token TTL in seconds (365 days). + * + * @since 4.1.0 + * + * @var int + */ + const MAX_REFRESH_TTL = 31536000; + /** * Verified access-token claims for the current request (user_id, kid). * @@ -123,6 +177,7 @@ public function __construct() { add_action( 'wp_ajax_tutor_generate_api_keys', __CLASS__ . '::generate_api_keys' ); add_action( 'wp_ajax_tutor_update_api_permission', __CLASS__ . '::update_api_permission' ); add_action( 'wp_ajax_tutor_revoke_api_keys', __CLASS__ . '::revoke_api_keys' ); + add_action( 'wp_ajax_tutor_save_rest_api_token_settings', __CLASS__ . '::save_token_settings' ); add_filter( 'determine_current_user', array( $this, 'api_auth' ) ); add_action( 'profile_update', array( $this, 'maybe_invalidate_tokens_on_profile_update' ), 10, 2 ); add_action( 'after_password_reset', array( $this, 'invalidate_user_tokens' ), 10, 1 ); @@ -130,6 +185,89 @@ public function __construct() { add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'enforce_actor_identity' ), 20, 3 ); } + /** + * Configured access JWT lifetime in seconds. + * + * @since 4.1.0 + * + * @return int + */ + public static function get_access_ttl() { + $ttl = (int) tutor_utils()->get_option( static::OPTION_ACCESS_TTL, static::ACCESS_TTL ); + + if ( $ttl < static::MIN_ACCESS_TTL ) { + return static::ACCESS_TTL; + } + + return min( $ttl, static::MAX_ACCESS_TTL ); + } + + /** + * Configured refresh token lifetime in seconds. + * + * @since 4.1.0 + * + * @return int + */ + public static function get_refresh_ttl() { + $ttl = (int) tutor_utils()->get_option( static::OPTION_REFRESH_TTL, static::REFRESH_TTL ); + + if ( $ttl < static::MIN_REFRESH_TTL ) { + return static::REFRESH_TTL; + } + + return min( $ttl, static::MAX_REFRESH_TTL ); + } + + /** + * Save Rest API token lifetime settings from Tools. + * + * @since 4.1.0 + * + * @return void + */ + public static function save_token_settings() { + tutor_utils()->checking_nonce(); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( tutor_utils()->error_message() ); + } + + $access_ttl = Input::post( static::OPTION_ACCESS_TTL, static::ACCESS_TTL, Input::TYPE_INT ); + $refresh_ttl = Input::post( static::OPTION_REFRESH_TTL, static::REFRESH_TTL, Input::TYPE_INT ); + + if ( $access_ttl < static::MIN_ACCESS_TTL || $access_ttl > static::MAX_ACCESS_TTL ) { + wp_send_json_error( + sprintf( + /* translators: 1: min seconds, 2: max seconds */ + __( 'Access token lifetime must be between %1$d and %2$d seconds.', 'tutor' ), + static::MIN_ACCESS_TTL, + static::MAX_ACCESS_TTL + ) + ); + } + + if ( $refresh_ttl < static::MIN_REFRESH_TTL || $refresh_ttl > static::MAX_REFRESH_TTL ) { + wp_send_json_error( + sprintf( + /* translators: 1: min seconds, 2: max seconds */ + __( 'Refresh token lifetime must be between %1$d and %2$d seconds.', 'tutor' ), + static::MIN_REFRESH_TTL, + static::MAX_REFRESH_TTL + ) + ); + } + + if ( $refresh_ttl <= $access_ttl ) { + wp_send_json_error( __( 'Refresh token lifetime must be greater than access token lifetime.', 'tutor' ) ); + } + + tutor_utils()->update_option( static::OPTION_ACCESS_TTL, $access_ttl ); + tutor_utils()->update_option( static::OPTION_REFRESH_TTL, $refresh_ttl ); + + wp_send_json_success( __( 'Token settings saved successfully', 'tutor' ) ); + } + /** * Authenticate Tutor REST requests from access JWT only. * @@ -1297,9 +1435,10 @@ private static function build_token_response( $user_id, $kid ) { * @return array{token:string,expires_in:int} */ private static function issue_access_token( $user_id, $kid ) { - $now = time(); - $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); - $kid = absint( $kid ); + $now = time(); + $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + $kid = absint( $kid ); + $access_ttl = self::get_access_ttl(); $header = self::base64url_encode( wp_json_encode( @@ -1314,7 +1453,7 @@ private static function issue_access_token( $user_id, $kid ) { array( 'sub' => (int) $user_id, 'iat' => $now, - 'exp' => $now + static::ACCESS_TTL, + 'exp' => $now + $access_ttl, 'iss' => 'tutor', 'tv' => $tv, 'kid' => $kid, @@ -1325,7 +1464,7 @@ private static function issue_access_token( $user_id, $kid ) { return array( 'token' => $header . '.' . $payload . '.' . $sig, - 'expires_in' => static::ACCESS_TTL, + 'expires_in' => $access_ttl, ); } @@ -1536,7 +1675,7 @@ function ( $row ) use ( $now ) { ); $list[] = array( 'hash' => $hash, - 'exp' => $now + static::REFRESH_TTL, + 'exp' => $now + self::get_refresh_ttl(), 'kid' => $kid, ); diff --git a/views/pages/tools/manage-tokens.php b/views/pages/tools/manage-tokens.php index bb22c33378..bd47cab550 100644 --- a/views/pages/tools/manage-tokens.php +++ b/views/pages/tools/manage-tokens.php @@ -29,6 +29,8 @@ $permissions = RestAuth::available_permissions(); $user = get_userdata( get_current_user_id() ); +$access_ttl = RestAuth::get_access_ttl(); +$refresh_ttl = RestAuth::get_refresh_ttl(); ?>
@@ -37,6 +39,87 @@
+
+
+
+ +
+
+
+ + + +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
From 8e646a2e922a181f4f984dfa18c6fab7dc7895d8 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 29 Sep 2026 13:17:34 +0600 Subject: [PATCH 014/218] fix(restapi): update permission checks for API key deletion in RestAuth class - Modified the permission check logic to ensure that only the 'Delete' and 'All' permissions are considered valid for API key deletion. - Updated documentation to clarify the changes and align with pre-4.0.10 Pro route allowlists, specifying that 'Write' and 'Read/Write' do not authorize DELETE actions. --- restapi/RestAuth.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 266cae2e8e..1949564266 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -791,7 +791,10 @@ public static function process_write_request() { } /** - * Whether the API key grants Delete (or Write/All). + * Whether the API key grants Delete (or All). + * + * Matches pre-4.0.10 Pro route allowlists: Delete and All only. + * Write / Read/Write do not authorize DELETE. * * @since 4.0.10 * @@ -803,7 +806,7 @@ public static function process_delete_request() { return false; } - return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + return in_array( $permission, array( static::DELETE, static::ALL ), true ); } /** From dfa00126d47f496c23f29a83ce5bc15cc9841cbb Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 29 Sep 2026 13:35:09 +0600 Subject: [PATCH 015/218] fix(restapi): update versioning in documentation for REST API classes - Updated the version annotations in the REST_Quiz and RestAuth classes to reflect the new version 4.2.0, ensuring accurate documentation of changes and features. - This change aligns the documentation with the latest updates and clarifies the version history for future reference. --- restapi/REST_Quiz.php | 4 +- restapi/RestAuth.php | 108 +++++++++++++++++++++--------------------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/restapi/REST_Quiz.php b/restapi/REST_Quiz.php index 1bc42338de..7933537ac9 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -367,7 +367,7 @@ public function quiz_attempt_details( WP_REST_Request $request ) { * Get quiz attempt answers for a single attempt. * * @since 1.7.1 - * @since 4.0.10 Scope by quiz_attempt_id to prevent cross-user answer leaks. + * @since 4.2.0 Scope by quiz_attempt_id to prevent cross-user answer leaks. * * @param int $attempt_id quiz attempt id. * @@ -452,7 +452,7 @@ protected function answer_titles_by_id( $id ) { /** * Strip is_correct from question answer options. * - * @since 4.0.10 + * @since 4.2.0 * * @param array $questions questions with answers. * diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 1949564266..310a3446f2 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -94,7 +94,7 @@ class RestAuth { /** * TTL value meaning no expiration (unlimited). * - * @since 4.1.0 + * @since 4.2.0 * * @var int */ @@ -103,7 +103,7 @@ class RestAuth { /** * Minimum token lifetime in days when not unlimited. * - * @since 4.1.0 + * @since 4.2.0 * * @var int */ @@ -112,7 +112,7 @@ class RestAuth { /** * Maximum token lifetime in days. * - * @since 4.1.0 + * @since 4.2.0 * * @var int */ @@ -135,7 +135,7 @@ class RestAuth { /** * Option key for access JWT lifetime (seconds). * - * @since 4.1.0 + * @since 4.2.0 * * @var string */ @@ -144,7 +144,7 @@ class RestAuth { /** * Option key for refresh token lifetime (seconds). * - * @since 4.1.0 + * @since 4.2.0 * * @var string */ @@ -179,7 +179,7 @@ public function __construct() { /** * Configured access JWT lifetime in seconds (0 = unlimited). * - * @since 4.1.0 + * @since 4.2.0 * * @return int */ @@ -193,7 +193,7 @@ public static function get_access_ttl() { /** * Configured refresh token lifetime in seconds (0 = unlimited). * - * @since 4.1.0 + * @since 4.2.0 * * @return int */ @@ -207,7 +207,7 @@ public static function get_refresh_ttl() { /** * Convert stored TTL seconds to whole days for admin UI. * - * @since 4.1.0 + * @since 4.2.0 * * @param int $seconds TTL in seconds (0 = unlimited). * @@ -226,7 +226,7 @@ public static function ttl_seconds_to_days( $seconds ) { /** * Convert admin UI days to stored TTL seconds. * - * @since 4.1.0 + * @since 4.2.0 * * @param int $days Days (0 = unlimited). * @@ -244,7 +244,7 @@ public static function ttl_days_to_seconds( $days ) { /** * Whether a day-based TTL is allowed (0 or 1–365). * - * @since 4.1.0 + * @since 4.2.0 * * @param int $days Days value from admin UI. * @@ -262,7 +262,7 @@ public static function is_valid_ttl_days( $days ) { /** * Normalize a stored TTL in seconds (0 = unlimited, otherwise clamp to max). * - * @since 4.1.0 + * @since 4.2.0 * * @param int $ttl Stored seconds. * @param int $default_seconds Fallback when value is invalid. @@ -286,7 +286,7 @@ private static function normalize_ttl_seconds( $ttl, $default_seconds ) { /** * Whether a refresh-token exp timestamp is still valid (0 = unlimited). * - * @since 4.1.0 + * @since 4.2.0 * * @param int $exp Expiration unix timestamp, or 0 for unlimited. * @param int $now Current unix timestamp. @@ -308,7 +308,7 @@ private static function is_refresh_exp_valid( $exp, $now ) { * Admin UI posts lifetimes in days; values are stored as seconds. * 0 means unlimited / no expiration. * - * @since 4.1.0 + * @since 4.2.0 * * @return void */ @@ -424,7 +424,7 @@ public static function is_tutor_api_request() { /** * Whether request is a Tutor auth login/refresh/logout route. * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -435,7 +435,7 @@ public static function is_auth_route() { /** * Whether request is the auth login route (requires API key + secret). * - * @since 4.1.0 + * @since 4.2.0 * * @return bool */ @@ -446,7 +446,7 @@ public static function is_login_route() { /** * Whether request is the auth refresh route. * - * @since 4.1.0 + * @since 4.2.0 * * @return bool */ @@ -457,7 +457,7 @@ public static function is_refresh_route() { /** * Whether request is the auth logout route. * - * @since 4.1.0 + * @since 4.2.0 * * @return bool */ @@ -468,7 +468,7 @@ public static function is_logout_route() { /** * Whether the request path matches a Tutor auth endpoint segment. * - * @since 4.1.0 + * @since 4.2.0 * * @param string $segment login|refresh|logout. * @@ -653,8 +653,8 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r * Login: from API key/secret headers. * All other Tutor REST routes: from the API key id (`kid`) bound into the access JWT. * - * @since 4.0.10 - * @since 4.1.0 Non-login routes resolve permission from the access token kid. + * @since 4.2.0 + * @since 4.2.0 Non-login routes resolve permission from the access token kid. * * @return string Empty when credentials/token are missing, invalid, or revoked. */ @@ -674,7 +674,7 @@ private static function get_api_key_permission() { /** * Permission from API key/secret headers. * - * @since 4.1.0 + * @since 4.2.0 * * @return string */ @@ -695,7 +695,7 @@ private static function get_permission_from_api_credentials() { /** * Permission for an API key usermeta row id (kid). * - * @since 4.1.0 + * @since 4.2.0 * * @param int $kid usermeta umeta_id of the API key row. * @@ -719,7 +719,7 @@ private static function get_permission_by_kid( $kid ) { /** * Extract permission string from API key meta JSON. * - * @since 4.1.0 + * @since 4.2.0 * * @param string $meta_value JSON meta value. * @@ -737,7 +737,7 @@ private static function permission_from_key_meta( $meta_value ) { /** * API key id (umeta_id) from the verified access token on this request. * - * @since 4.1.0 + * @since 4.2.0 * * @return int */ @@ -761,7 +761,7 @@ private static function get_access_token_kid() { /** * Whether the API key grants Read (or higher). * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -777,7 +777,7 @@ public static function process_read_request() { /** * Whether the API key grants Write (or higher). * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -796,7 +796,7 @@ public static function process_write_request() { * Matches pre-4.0.10 Pro route allowlists: Delete and All only. * Write / Read/Write do not authorize DELETE. * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -815,9 +815,9 @@ public static function process_delete_request() { * Login uses API key/secret. All other routes use the access token's bound key permission. * * @since 2.2.1 - * @since 4.0.10 Honor key permission; accept Tutor-Api-Key headers. - * @since 4.0.10 Delegate to process_read/write/delete_request(). - * @since 4.1.0 Login-only key/secret; other routes use JWT kid permission. + * @since 4.2.0 Honor key permission; accept Tutor-Api-Key headers. + * @since 4.2.0 Delegate to process_read/write/delete_request(). + * @since 4.2.0 Login-only key/secret; other routes use JWT kid permission. * * @return boolean */ @@ -843,7 +843,7 @@ public static function process_api_request() { /** * Whether the request has a JWT-authenticated WordPress user. * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -854,7 +854,7 @@ public static function has_authenticated_user() { /** * Read-capable API key and an authenticated end user (JWT). * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -865,7 +865,7 @@ public static function process_authenticated_read_request() { /** * Write-capable API key and an authenticated end user (JWT). * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -876,7 +876,7 @@ public static function process_authenticated_write_request() { /** * Delete-capable API key and an authenticated end user (JWT). * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -889,7 +889,7 @@ public static function process_authenticated_delete_request() { * * Used when the route does not declare a specific read/write/delete check. * - * @since 4.0.10 + * @since 4.2.0 * * @return bool */ @@ -900,7 +900,7 @@ public static function process_authenticated_api_request() { /** * Whether the current user may act as the given user (self or privileged admin). * - * @since 4.0.10 + * @since 4.2.0 * * @param int $target_user_id target user id. * @@ -926,7 +926,7 @@ public static function can_act_as_user( $target_user_id ) { * * Self, admin, or instructor/admin with course content access. * - * @since 4.0.10 + * @since 4.2.0 * * @param int $student_id student user id. * @param int $course_id course id when known. @@ -954,7 +954,7 @@ public static function can_act_as_student( $student_id, $course_id = 0 ) { * routes and unauthenticated requests are skipped. Object-level checks * can plug in via the `tutor_rest_enforce_object_access` filter. * - * @since 4.1.0 + * @since 4.2.0 * * @param mixed $response response. * @param array $handler handler. @@ -1024,7 +1024,7 @@ public static function enforce_actor_identity( $response, $handler, $request ) { /** * Object-level access for extensions (Tutor Pro ObjectAccess). * - * @since 4.1.0 + * @since 4.2.0 * * @param true|\WP_Error $result Pass-through true, or WP_Error to deny. * @param WP_REST_Request $request Request. @@ -1041,7 +1041,7 @@ public static function enforce_actor_identity( $response, $handler, $request ) { /** * Permission: valid API key and may view course learning content. * - * @since 4.0.10 + * @since 4.2.0 * * @param WP_REST_Request $request request. * @@ -1063,7 +1063,7 @@ public static function permission_course_content( WP_REST_Request $request ) { /** * Permission: topics by course_id. * - * @since 4.0.10 + * @since 4.2.0 * * @param WP_REST_Request $request request. * @@ -1080,7 +1080,7 @@ public static function permission_topics( WP_REST_Request $request ) { /** * Permission: lessons or quizzes listed by topic_id. * - * @since 4.0.10 + * @since 4.2.0 * * @param WP_REST_Request $request request. * @@ -1100,7 +1100,7 @@ public static function permission_by_topic( WP_REST_Request $request ) { /** * Permission: quiz by quiz id. * - * @since 4.0.10 + * @since 4.2.0 * * @param WP_REST_Request $request request. * @@ -1120,7 +1120,7 @@ public static function permission_quiz( WP_REST_Request $request ) { /** * Whether the user may view full course learning content. * - * @since 4.0.10 + * @since 4.2.0 * * @param int $course_id course id. * @param int $user_id user id. @@ -1152,7 +1152,7 @@ public static function can_view_course_content( $course_id, $user_id = 0 ) { /** * Whether answer keys (is_correct) may be revealed. * - * @since 4.0.10 + * @since 4.2.0 * * @param int $quiz_id quiz id. * @param int $user_id user id. @@ -1178,7 +1178,7 @@ public static function can_reveal_quiz_answers( $quiz_id, $user_id = 0 ) { /** * Whether viewer may see private user fields (email, login, registered). * - * @since 4.0.10 + * @since 4.2.0 * * @param int $target_user_id target user. * @param int $viewer_id viewer. @@ -1222,8 +1222,8 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id * Requires a valid Read-capable API key/secret (permission_callback). The key id * is bound into issued tokens so later requests need only the Bearer token. * - * @since 4.0.10 - * @since 4.1.0 Bind API key id (kid) into access and refresh tokens. + * @since 4.2.0 + * @since 4.2.0 Bind API key id (kid) into access and refresh tokens. * * @param WP_REST_Request $request request. * @@ -1295,8 +1295,8 @@ public static function rest_login( WP_REST_Request $request ) { /** * Refresh access token (rotates refresh token). * - * @since 4.0.10 - * @since 4.1.0 No API key/secret; reuses kid stored with the refresh token. + * @since 4.2.0 + * @since 4.2.0 No API key/secret; reuses kid stored with the refresh token. * * @param WP_REST_Request $request request. * @@ -1340,7 +1340,7 @@ public static function rest_refresh( WP_REST_Request $request ) { /** * Logout — delete refresh token(s). * - * @since 4.0.10 + * @since 4.2.0 * * @param WP_REST_Request $request request. * @@ -1378,7 +1378,7 @@ public static function rest_logout( WP_REST_Request $request ) { /** * Invalidate tokens when password changes on profile update. * - * @since 4.0.10 + * @since 4.2.0 * * @param int $user_id user id. * @param \WP_User $old_user_data old user. @@ -1399,7 +1399,7 @@ public function maybe_invalidate_tokens_on_profile_update( $user_id, $old_user_d /** * Bump token_version and delete refresh tokens. * - * @since 4.0.10 + * @since 4.2.0 * * @param int|\WP_User $user user id or object. * @@ -1824,7 +1824,7 @@ private static function find_user_id_by_refresh_token( $token ) { /** * Find refresh session (user id + kid) for a refresh token. * - * @since 4.1.0 + * @since 4.2.0 * * @param string $token refresh token. * From 0cdf49f036a656a1afc9af43e6786da630bdb44d Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Wed, 30 Sep 2026 10:31:25 +0600 Subject: [PATCH 016/218] fix(restapi): update permission callback and enhance review privacy in REST API - Changed the permission callback in the RestAPI class to use 'permission_course_content' for better access control. - Updated the REST_Course class to unset 'post_password' along with 'filter' for improved data handling. - Enhanced the REST_Rating class to ensure that private user fields are hidden from reviews unless the user has permission to view them. - Modified the can_view_course_content method in RestAuth to check for valid course types, improving security and data integrity. --- classes/RestAPI.php | 2 +- restapi/REST_Course.php | 2 +- restapi/REST_Rating.php | 23 +++++++++++------------ restapi/RestAuth.php | 14 ++++++++++---- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index 76caa587fe..9a1edbffab 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -294,7 +294,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) ); diff --git a/restapi/REST_Course.php b/restapi/REST_Course.php index 0eb2ab3512..7fe543982c 100644 --- a/restapi/REST_Course.php +++ b/restapi/REST_Course.php @@ -146,7 +146,7 @@ public function course( WP_REST_Request $request ) { } $item = (object) $post->to_array(); - unset( $item->filter ); + unset( $item->filter, $item->post_password ); $category = wp_get_post_terms( $post->ID, $this->course_cat_tax ); diff --git a/restapi/REST_Rating.php b/restapi/REST_Rating.php index ead4aadb6b..08b2a029ae 100644 --- a/restapi/REST_Rating.php +++ b/restapi/REST_Rating.php @@ -66,23 +66,22 @@ public function course_rating( WP_REST_Request $request ) { $offset = ! empty( $offset ) ? $offset : 0; $limit = ! empty( $limit ) ? $limit : 10; - $ratings = tutor_utils()->get_course_rating( $this->post_id ); - $ratings->reviews = tutor_utils()->get_course_reviews( $this->post_id, $offset, $limit, false, array( 'approved' ) ); + $ratings = tutor_utils()->get_course_rating( $this->post_id ); + $reviews = tutor_utils()->get_course_reviews( $this->post_id, $offset, $limit, false, array( 'approved' ) ); - if ( ! empty( $ratings ) ) { - $response = array( - 'code' => 'success', - 'message' => __( 'Course rating retrieved successfully', 'tutor' ), - 'data' => $ratings, - ); + $ratings->reviews = is_array( $reviews ) ? $reviews : array(); - return self::send( $response ); + foreach ( $ratings->reviews as $review ) { + $user_id = isset( $review->user_id ) ? (int) $review->user_id : 0; + if ( ! RestAuth::can_view_user_private_fields( $user_id ) ) { + unset( $review->comment_author_email ); + } } $response = array( - 'code' => 'not_found', - 'message' => __( 'Rating not found for given ID', 'tutor' ), - 'data' => array(), + 'code' => 'success', + 'message' => __( 'Course rating retrieved successfully', 'tutor' ), + 'data' => $ratings, ); return self::send( $response ); diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 310a3446f2..313d3ee7e7 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -13,6 +13,7 @@ namespace TUTOR; use Tutor\Helpers\QueryHelper; +use Tutor\Models\CourseModel; use Tutor\Models\EnrollmentModel; use Tutor\Models\QuizModel; use WP_REST_Request; @@ -1129,7 +1130,7 @@ public static function permission_quiz( WP_REST_Request $request ) { */ public static function can_view_course_content( $course_id, $user_id = 0 ) { $course_id = absint( $course_id ); - if ( ! $course_id ) { + if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { return false; } @@ -1189,15 +1190,20 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id $target_user_id = absint( $target_user_id ); $viewer_id = $viewer_id ? absint( $viewer_id ) : get_current_user_id(); - if ( ! $target_user_id || ! $viewer_id ) { + if ( ! $viewer_id ) { return false; } - if ( $target_user_id === $viewer_id ) { + // Privileged users may see private fields, including guest reviews (user_id 0). + if ( user_can( $viewer_id, 'list_users' ) ) { return true; } - if ( user_can( $viewer_id, 'list_users' ) ) { + if ( ! $target_user_id ) { + return false; + } + + if ( $target_user_id === $viewer_id ) { return true; } From b8c09a309b821c5b54bb2c3562c75dd80b98297f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Wed, 30 Sep 2026 12:52:26 +0600 Subject: [PATCH 017/218] refactor(restapi): streamline post retrieval in REST API classes - Introduced a new REST_Posts class to encapsulate shared post retrieval logic, enhancing code reusability and maintainability. - Updated REST_Course_Announcement, REST_Course, REST_Quiz, and REST_Topic classes to utilize the new REST_Posts methods for fetching published child posts and sanitizing course content items. - Improved data handling by ensuring only published posts are retrieved, enhancing the overall security and integrity of the API responses. --- restapi/REST_Course.php | 150 +++++++++++++++++++++++---- restapi/REST_Course_Announcement.php | 11 +- restapi/REST_Posts.php | 75 ++++++++++++++ restapi/REST_Quiz.php | 124 +++++++++------------- restapi/REST_Topic.php | 6 +- 5 files changed, 261 insertions(+), 105 deletions(-) create mode 100644 restapi/REST_Posts.php diff --git a/restapi/REST_Course.php b/restapi/REST_Course.php index 7fe543982c..e3f0b23d76 100644 --- a/restapi/REST_Course.php +++ b/restapi/REST_Course.php @@ -71,13 +71,16 @@ public function __construct() { * Get course list along with pagination, categories, tags * author details, reviews * + * @since 1.7.1 + * @since 4.2.0 Whitelist order/orderby; isolate price sort path. + * * @param WP_REST_Request $request request data. * * @return WP_REST_Response */ public function course( WP_REST_Request $request ) { - $order = sanitize_text_field( $request->get_param( 'order' ) ); - $orderby = sanitize_text_field( $request->get_param( 'orderby' ) ); + $order = self::sanitize_course_order( $request->get_param( 'order' ) ); + $orderby = self::sanitize_course_orderby( $request->get_param( 'orderby' ) ); $paged = sanitize_text_field( $request->get_param( 'paged' ) ); $categories = null; if ( isset( $request['categories'] ) ) { @@ -95,8 +98,8 @@ public function course( WP_REST_Request $request ) { 'post_status' => 'publish', 'posts_per_page' => $post_per_page, 'paged' => $paged ? $paged : 1, - 'order' => $order ? $order : 'ASC', - 'orderby' => $orderby ? $orderby : 'title', + 'order' => $order, + 'orderby' => $orderby, ); if ( isset( $categories ) || isset( $tags ) ) { @@ -117,15 +120,7 @@ public function course( WP_REST_Request $request ) { } if ( 'price' === $orderby ) { - $args['post_type'] = 'product'; - $args['meta_key'] = '_regular_price'; - $args['meta_query'] = array( - 'relation' => 'AND', - array( - 'key' => '_tutor_product', - 'value' => 'yes', - ), - ); + $args = self::apply_price_order_args( $args ); } $args = apply_filters( 'tutor_rest_course_query_args', $args ); @@ -313,23 +308,37 @@ public function validate_terms( array $post ) { */ public function course_contents( WP_REST_Request $request ) { $course_id = $request->get_param( 'id' ); - $topics = tutor_utils()->get_topics( $course_id ); + $topics = tutor_utils()->get_topics( + $course_id, + array( + 'post_status' => 'publish', + ) + ); if ( $topics->have_posts() ) { $data = array(); - foreach ( $topics->get_posts() as $post ) { + foreach ( $topics->get_posts() as $topic ) { $current_topic = array( - 'id' => $post->ID, - 'title' => $post->post_title, - 'summary' => $post->post_content, + 'id' => $topic->ID, + 'title' => $topic->post_title, + 'summary' => $topic->post_content, 'contents' => array(), ); - $topic_contents = tutor_utils()->get_course_contents_by_topic( $post->ID, -1 ); + $topic_contents = tutor_utils()->get_course_contents_by_topic( + $topic->ID, + -1, + array( + 'post_status' => 'publish', + ) + ); if ( $topic_contents->have_posts() ) { - foreach ( $topic_contents->get_posts() as $post ) { - array_push( $current_topic['contents'], $post ); + foreach ( $topic_contents->get_posts() as $content_post ) { + array_push( + $current_topic['contents'], + self::sanitize_course_content_item( $content_post ) + ); } } @@ -352,4 +361,103 @@ public function course_contents( WP_REST_Request $request ) { return self::send( $response ); } + + /** + * Map a curriculum post to a student-safe DTO (no post_password/guid/etc). + * + * @since 4.2.0 + * + * @param WP_Post $post Curriculum post. + * + * @return array + */ + private static function sanitize_course_content_item( WP_Post $post ) { + return array( + 'ID' => (int) $post->ID, + 'post_title' => $post->post_title, + 'post_content' => $post->post_content, + 'post_name' => $post->post_name, + 'post_type' => $post->post_type, + 'menu_order' => (int) $post->menu_order, + ); + } + + /** + * Whitelist WP_Query order direction for the course list. + * + * @since 4.2.0 + * + * @param mixed $order Raw request order value. + * + * @return string ASC or DESC. + */ + private static function sanitize_course_order( $order ) { + $order = strtoupper( sanitize_text_field( (string) $order ) ); + $allowed = array( 'ASC', 'DESC' ); + + if ( in_array( $order, $allowed, true ) ) { + return $order; + } + + return 'ASC'; + } + + /** + * Whitelist WP_Query orderby for the course list. + * + * `price` is accepted here but applied only via apply_price_order_args(). + * + * @since 4.2.0 + * + * @param mixed $orderby Raw request orderby value. + * + * @return string Allowed orderby key. + */ + private static function sanitize_course_orderby( $orderby ) { + $orderby = sanitize_text_field( (string) $orderby ); + $allowed = array( + 'title', + 'date', + 'modified', + 'ID', + 'name', + 'author', + 'menu_order', + 'price', + ); + + if ( in_array( $orderby, $allowed, true ) ) { + return $orderby; + } + + return 'title'; + } + + /** + * Apply the reviewed price-sort path (Tutor-linked WooCommerce products only). + * + * Client input never chooses post_type or meta_key; those are fixed here. + * + * @since 4.2.0 + * + * @param array $args WP_Query arguments. + * + * @return array + */ + private static function apply_price_order_args( array $args ) { + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required to sort Tutor-linked WC products by numeric regular price. + $args['post_type'] = 'product'; + $args['meta_key'] = '_regular_price'; + $args['orderby'] = 'meta_value_num'; + $args['meta_query'] = array( + 'relation' => 'AND', + array( + 'key' => '_tutor_product', + 'value' => 'yes', + ), + ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_query + + return $args; + } } diff --git a/restapi/REST_Course_Announcement.php b/restapi/REST_Course_Announcement.php index 337e3ef139..ebed0dc0f8 100644 --- a/restapi/REST_Course_Announcement.php +++ b/restapi/REST_Course_Announcement.php @@ -52,10 +52,13 @@ class REST_Course_Announcement { public function course_announcement( WP_REST_Request $request ) { $this->post_parent = $request->get_param( 'id' ); - global $wpdb; - - $result = $wpdb->get_results( - $wpdb->prepare( "SELECT ID, post_title, post_content, post_name FROM {$wpdb->posts} WHERE post_type = %s AND post_parent = %d", $this->post_type, $this->post_parent ) + $result = REST_Posts::get_published_child_posts( + $this->post_type, + $this->post_parent, + array( + 'orderby' => 'date', + 'order' => 'DESC', + ) ); if ( count( $result ) > 0 ) { diff --git a/restapi/REST_Posts.php b/restapi/REST_Posts.php new file mode 100644 index 0000000000..d10ff908ce --- /dev/null +++ b/restapi/REST_Posts.php @@ -0,0 +1,75 @@ + + * @link https://themeum.com + * @since 4.2.0 + */ + +namespace TUTOR; + +use WP_Post; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Class REST_Posts + */ +class REST_Posts { + + /** + * Map a post to public REST fields (no password/guid/status/etc). + * + * @since 4.2.0 + * + * @param WP_Post $post Post object. + * + * @return object + */ + public static function to_public_post_dto( WP_Post $post ) { + return (object) array( + 'ID' => (int) $post->ID, + 'post_title' => $post->post_title, + 'post_content' => $post->post_content, + 'post_name' => $post->post_name, + ); + } + + /** + * Fetch published child posts as public DTOs. + * + * @since 4.2.0 + * + * @param string $post_type Post type slug. + * @param int $post_parent Parent post ID. + * @param array $args Optional get_posts() overrides (orderby, order, …). + * + * @return object[] + */ + public static function get_published_child_posts( $post_type, $post_parent, $args = array() ) { + $query_args = wp_parse_args( + $args, + array( + 'post_type' => $post_type, + 'post_parent' => absint( $post_parent ), + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'menu_order', + 'order' => 'ASC', + ) + ); + + $posts = get_posts( $query_args ); + $result = array(); + + foreach ( $posts as $post ) { + $result[] = self::to_public_post_dto( $post ); + } + + return $result; + } +} diff --git a/restapi/REST_Quiz.php b/restapi/REST_Quiz.php index 7933537ac9..4bb8040127 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -80,27 +80,9 @@ public function get_quiz( WP_REST_Request $request ) { global $wpdb; $quiz_id = Input::sanitize( $request->get_param( 'id' ), 0, Input::TYPE_INT ); - $wpdb->q_t = $wpdb->prefix . $this->t_quiz_question; // Question table. - - $wpdb->q_a_t = $wpdb->prefix . $this->t_quiz_ques_ans; // Question answer table. + $quiz_post = get_post( $quiz_id ); - $quiz = $wpdb->get_row( - $wpdb->prepare( - "SELECT - ID, - post_title, - post_content, - post_name - FROM {$wpdb->posts} - WHERE post_type = %s - AND ID = %d - ", - $this->post_type, - $quiz_id - ) - ); - - if ( ! isset( $quiz ) ) { + if ( ! $quiz_post || $this->post_type !== $quiz_post->post_type || 'publish' !== $quiz_post->post_status ) { $response = array( 'code' => 'not_found', 'message' => __( 'Quiz not found for given ID', 'tutor' ), @@ -109,8 +91,13 @@ public function get_quiz( WP_REST_Request $request ) { return self::send( $response ); } + $quiz = REST_Posts::to_public_post_dto( $quiz_post ); + + $wpdb->q_t = $wpdb->prefix . $this->t_quiz_question; // Question table. + $quiz->quiz_settings = get_post_meta( $quiz->ID, 'tutor_quiz_option', false ); - $questions = $wpdb->get_results( + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom tutor_quiz_questions table; no WP API. + $questions = $wpdb->get_results( $wpdb->prepare( "SELECT question_id, @@ -134,7 +121,7 @@ public function get_quiz( WP_REST_Request $request ) { } if ( ! RestAuth::can_reveal_quiz_answers( $quiz_id ) ) { - $questions = self::strip_is_correct_from_questions( $questions ); + $questions = self::redact_answers_for_student( $questions ); } $quiz->quiz_questions = $questions; @@ -160,31 +147,11 @@ public function get_quiz( WP_REST_Request $request ) { public function quiz_with_settings( WP_REST_Request $request ) { $this->post_parent = $request->get_param( 'topic_id' ); - global $wpdb; - - $quizs = $wpdb->get_results( - $wpdb->prepare( - "SELECT - ID, - post_title, - post_content, - post_name - FROM {$wpdb->posts} - WHERE post_type = %s - AND post_parent = %d - ", - $this->post_type, - $this->post_parent - ) - ); - - $data = array(); + $data = REST_Posts::get_published_child_posts( $this->post_type, $this->post_parent ); - if ( count( $quizs ) > 0 ) { - foreach ( $quizs as $quiz ) { + if ( count( $data ) > 0 ) { + foreach ( $data as $quiz ) { $quiz->quiz_settings = get_post_meta( $quiz->ID, 'tutor_quiz_option', false ); - - array_push( $data, $quiz ); } $response = array( @@ -217,8 +184,7 @@ public function quiz_question_ans( WP_REST_Request $request ) { $wpdb->q_t = $wpdb->prefix . $this->t_quiz_question; // Question table. - $wpdb->q_a_t = $wpdb->prefix . $this->t_quiz_ques_ans; // Question answer table. - + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom tutor_quiz_questions table; no WP API. $quizs = $wpdb->get_results( $wpdb->prepare( "SELECT @@ -241,27 +207,14 @@ public function quiz_question_ans( WP_REST_Request $request ) { // Un-serialized question settings. $quiz->question_settings = maybe_unserialize( $quiz->question_settings ); - // question options with correct ans. - $options = $wpdb->get_results( - $wpdb->prepare( - "SELECT - answer_id, - answer_title, - is_correct FROM {$wpdb->q_a_t} - WHERE belongs_question_id = %d - ", - $quiz->question_id - ) - ); - - // set question_answers as quiz property. - $quiz->question_answers = $options; + // Question options (redacted later when answers must stay hidden). + $quiz->question_answers = QuizModel::get_question_answers( $quiz->question_id, $quiz->question_type ); array_push( $data, $quiz ); } if ( ! RestAuth::can_reveal_quiz_answers( (int) $this->post_parent ) ) { - $data = self::strip_is_correct_from_questions( $data ); + $data = self::redact_answers_for_student( $data ); } $response = array( @@ -298,6 +251,7 @@ public function quiz_attempt_details( WP_REST_Request $request ) { $wpdb->quiz_attempt = $wpdb->prefix . $this->t_quiz_attempt; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom tutor_quiz_attempts table; no WP API. $attempts = $wpdb->get_results( $wpdb->prepare( "SELECT @@ -312,8 +266,8 @@ public function quiz_attempt_details( WP_REST_Request $request ) { att.attempt_started_at, att.attempt_ended_at, att.is_manually_reviewed, - att.manually_reviewed_at - FROM {$wpdb->quiz_attempt} att + att.manually_reviewed_at + FROM {$wpdb->quiz_attempt} att WHERE att.quiz_id = %d ", $quiz_id @@ -384,7 +338,7 @@ protected function get_quiz_attempt_ans( $attempt_id ) { $wpdb->quiz_attempt_ans = $wpdb->prefix . $this->t_quiz_attempt_ans; $wpdb->quiz_question = $wpdb->prefix . $this->t_quiz_question; - // get attempt answers. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom attempt/question tables; no WP API. $answers = $wpdb->get_results( $wpdb->prepare( "SELECT @@ -394,7 +348,7 @@ protected function get_quiz_attempt_ans( $attempt_id ) { att_ans.achieved_mark, att_ans.minus_mark, att_ans.is_correct FROM {$wpdb->quiz_attempt_ans} as att_ans - JOIN {$wpdb->quiz_question} q ON q.question_id = att_ans.question_id + JOIN {$wpdb->quiz_question} q ON q.question_id = att_ans.question_id WHERE att_ans.quiz_attempt_id = %d ", $attempt_id @@ -438,35 +392,55 @@ protected function answer_titles_by_id( $id ) { $array = QueryHelper::prepare_in_clause( $ids ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom answers table; no WP API. $results = $wpdb->get_results( "SELECT answer_title - FROM {$wpdb->t_quiz_ques_ans} - WHERE - answer_id IN ({$array})" //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + FROM {$wpdb->t_quiz_ques_ans} + WHERE + answer_id IN ({$array})" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- IDs prepared via QueryHelper::prepare_in_clause. ); return $results; } /** - * Strip is_correct from question answer options. + * Redact quiz solution fields for students who cannot reveal answers. + * + * Choice types keep option titles but drop correctness markers. Types where + * the answer row itself is the key (FIB, matching, ordering, etc.) return + * an empty answers list. * * @since 4.2.0 * - * @param array $questions questions with answers. + * @param array $questions Questions with answers. * * @return array */ - private static function strip_is_correct_from_questions( $questions ) { + private static function redact_answers_for_student( $questions ) { + $choice_types = array( + QuizModel::QUESTION_TYPE_TRUE_FALSE, + QuizModel::QUESTION_TYPE_SINGLE_CHOICE, + QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE, + ); + foreach ( $questions as $question ) { + $type = isset( $question->question_type ) ? (string) $question->question_type : ''; + + if ( ! in_array( $type, $choice_types, true ) ) { + $question->question_answers = array(); + continue; + } + if ( empty( $question->question_answers ) || ! is_array( $question->question_answers ) ) { continue; } + foreach ( $question->question_answers as $answer ) { - if ( is_object( $answer ) && isset( $answer->is_correct ) ) { - unset( $answer->is_correct ); + if ( ! is_object( $answer ) ) { + continue; } + unset( $answer->is_correct, $answer->answer_two_gap_match ); } } diff --git a/restapi/REST_Topic.php b/restapi/REST_Topic.php index 368f98350b..7c18a5c499 100644 --- a/restapi/REST_Topic.php +++ b/restapi/REST_Topic.php @@ -61,11 +61,7 @@ public function course_topic( WP_REST_Request $request ) { return self::send( $response ); } - global $wpdb; - - $result = $wpdb->get_results( - $wpdb->prepare( "SELECT ID, post_title, post_content, post_name FROM {$wpdb->posts} WHERE post_type = %s AND post_parent = %d", $this->post_type, $this->post_parent ) - ); + $result = REST_Posts::get_published_child_posts( $this->post_type, $this->post_parent ); if ( count( $result ) > 0 ) { $response = array( From 7a0f5ab40236a1cd58e53b6da70ce7953d535879 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 12:53:47 +0600 Subject: [PATCH 018/218] Fix: blocked injection from unescaped order by in get_row helper --- helpers/QueryHelper.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 41b7e4d8f1..868306c2f9 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -805,15 +805,16 @@ public static function query( $table, $args = array() ) { public static function get_row( string $table, array $where, string $order_by, string $order = 'DESC', string $output = 'OBJECT' ) { global $wpdb; - $table = self::prepare_table_name( $table ); - $where_clause = self::prepare_where_clause( $where ); + $table = self::prepare_table_name( $table ); + $where_clause = self::prepare_where_clause( $where ); + $order_by_clause = self::prepare_order_clause( $order_by, $order ); //phpcs:disable $query = $wpdb->prepare( "SELECT * FROM {$table} WHERE {$where_clause} - ORDER BY {$order_by} {$order} + {$order_by_clause} LIMIT %d ", 1 From 19864c7e60b1fe8376a94a6d31236fe86b5a8175 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Wed, 30 Sep 2026 12:57:54 +0600 Subject: [PATCH 019/218] refactor(restapi): simplify ID argument validation in REST API routes - Introduced a new private method `get_id_arg_schema` in the RestAPI class to standardize the ID argument schema across multiple REST routes. - Replaced inline validation logic with the new method for improved code readability and maintainability. - Ensured consistent validation and sanitization for ID parameters in various API endpoints, enhancing overall security and code reusability. --- classes/RestAPI.php | 82 +++++++++++++++------------------------------ 1 file changed, 27 insertions(+), 55 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index 9a1edbffab..d6b2625727 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -224,6 +224,22 @@ private function loader( $class_name ) { require_once $real_file; } + /** + * Integer ID argument schema for REST routes. + * + * @since 4.2.0 + * + * @return array + */ + private function get_id_arg_schema() { + return array( + 'type' => 'integer', + 'minimum' => 1, + 'sanitize_callback' => 'absint', + 'required' => true, + ); + } + /** * Initialize routes * @@ -288,11 +304,7 @@ public function init_routes() { 'course_detail', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) @@ -309,11 +321,7 @@ public function init_routes() { 'course_topic', ), 'args' => array( - 'course_id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'course_id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_topics' ), ) @@ -330,11 +338,7 @@ public function init_routes() { 'topic_lesson', ), 'args' => array( - 'topic_id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'topic_id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) @@ -351,11 +355,7 @@ public function init_routes() { 'course_announcement', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) @@ -372,11 +372,7 @@ public function init_routes() { 'quiz_with_settings', ), 'args' => array( - 'topic_id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'topic_id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) @@ -393,11 +389,7 @@ public function init_routes() { 'get_quiz', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) @@ -414,11 +406,7 @@ public function init_routes() { 'quiz_question_ans', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) @@ -435,11 +423,7 @@ public function init_routes() { 'quiz_attempt_details', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) @@ -456,11 +440,7 @@ public function init_routes() { 'author_detail', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'process_api_request' ), ) @@ -477,11 +457,7 @@ public function init_routes() { 'course_rating', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'process_api_request' ), ) @@ -498,11 +474,7 @@ public function init_routes() { 'course_contents', ), 'args' => array( - 'id' => array( - 'validate_callback' => function ( $param ) { - return is_numeric( $param ); - }, - ), + 'id' => $this->get_id_arg_schema(), ), 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) From 6ce8240daf359b77d6711a47e58853827e4b9591 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Wed, 30 Sep 2026 13:12:07 +0600 Subject: [PATCH 020/218] fix(restapi): refine JWT authentication logic in RestAuth class - Updated the api_auth method to ensure that user identity is strictly derived from a valid access token, aligning with kid-based API permission. - Removed unnecessary checks for user_id when processing tutor API requests, enhancing the clarity and efficiency of the authentication flow. - Improved error handling by returning false when the access token is invalid or missing, ensuring better security and response consistency. --- restapi/RestAuth.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 313d3ee7e7..11c2becf51 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -368,26 +368,29 @@ public static function save_token_settings() { /** * Authenticate Tutor REST requests from access JWT only. * + * Identity comes only from a valid + * access token so it stays aligned with kid-based API permission. + * * @since 2.7.1 - * @since 4.0.8 Identity is never taken from the API key owner. + * @since 4.2.0 Ignore cookie sessions on tutor/*; JWT-only identity. * * @param int|false $user_id user id. * * @return int|false */ public function api_auth( $user_id ) { - if ( ! empty( $user_id ) || ! static::is_tutor_api_request() ) { + if ( ! static::is_tutor_api_request() ) { return $user_id; } $token = self::get_access_token_from_request(); if ( ! $token ) { - return $user_id; + return false; } $jwt_user_id = self::verify_access_token( $token ); if ( ! $jwt_user_id ) { - return $user_id; + return false; } return $jwt_user_id; From 7a1fe2b9cfe03573c1b36f92bb307de1808e0e21 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Wed, 30 Sep 2026 13:29:54 +0600 Subject: [PATCH 021/218] refactor(restapi): enhance JWT handling in RestAuth class - Replaced manual JWT encoding and decoding logic with dedicated methods `encode_jwt` and `decode_jwt` for improved clarity and maintainability. - Updated the `issue_refresh_token` method to issue signed refresh JWTs, streamlining the refresh token process and enhancing security. - Improved the `find_refresh_session` method to verify refresh JWTs, ensuring that user-specific checks are performed efficiently. - Added detailed documentation for new JWT methods, clarifying their purpose and usage within the RestAuth class. --- restapi/RestAuth.php | 287 ++++++++++++++++++++++++------------------- 1 file changed, 162 insertions(+), 125 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 11c2becf51..d46e303f66 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -72,7 +72,7 @@ class RestAuth { const KEYS_USER_META_KEY = 'tutor-api-key-secret'; /** - * Usermeta: refresh token hashes. + * Usermeta: refresh token hashes (keyed per user; lookup via signed JWT `sub`). * * @var string */ @@ -1548,15 +1548,6 @@ private static function issue_access_token( $user_id, $kid ) { $kid = absint( $kid ); $access_ttl = self::get_access_ttl(); - $header = self::base64url_encode( - wp_json_encode( - array( - 'alg' => 'HS256', - 'typ' => 'JWT', - ) - ) - ); - $claims = array( 'sub' => (int) $user_id, 'iat' => $now, @@ -1569,11 +1560,8 @@ private static function issue_access_token( $user_id, $kid ) { $claims['exp'] = $now + $access_ttl; } - $payload = self::base64url_encode( wp_json_encode( $claims ) ); - $sig = self::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, self::jwt_secret(), true ) ); - return array( - 'token' => $header . '.' . $payload . '.' . $sig, + 'token' => self::encode_jwt( $claims ), 'expires_in' => $access_ttl, ); } @@ -1588,33 +1576,13 @@ private static function issue_access_token( $user_id, $kid ) { private static function verify_access_token( $jwt ) { self::$verified_token_claims = null; - $parts = explode( '.', $jwt ); - if ( 3 !== count( $parts ) ) { + $payload = self::decode_jwt( $jwt ); + if ( ! $payload ) { return 0; } - list( $header_b64, $payload_b64, $sig_b64 ) = $parts; - - $expected = self::base64url_encode( - hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, self::jwt_secret(), true ) - ); - - if ( ! hash_equals( $expected, $sig_b64 ) ) { - return 0; - } - - $payload_json = self::base64url_decode( $payload_b64 ); - $payload = json_decode( $payload_json ); - if ( ! is_object( $payload ) || empty( $payload->sub ) ) { - return 0; - } - - // Missing exp means unlimited; otherwise require a future expiration. - if ( isset( $payload->exp ) && (int) $payload->exp < time() ) { - return 0; - } - - if ( empty( $payload->iss ) || 'tutor' !== $payload->iss ) { + // Access tokens must not carry the refresh typ claim. + if ( ! empty( $payload->typ ) && 'refresh' === $payload->typ ) { return 0; } @@ -1667,6 +1635,105 @@ private static function jwt_secret() { return $secret; } + /** + * Encode claims as an HS256 JWT. + * + * @since 4.2.0 + * + * @param array $claims JWT payload claims. + * + * @return string + */ + private static function encode_jwt( array $claims ) { + $header = self::base64url_encode( + wp_json_encode( + array( + 'alg' => 'HS256', + 'typ' => 'JWT', + ) + ) + ); + + $payload = self::base64url_encode( wp_json_encode( $claims ) ); + $sig = self::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, self::jwt_secret(), true ) ); + + return $header . '.' . $payload . '.' . $sig; + } + + /** + * Decode and verify HS256 JWT signature + shared claims (iss, optional exp). + * + * @since 4.2.0 + * + * @param string $jwt Token string. + * + * @return object|null Payload object on success, null on failure. + */ + private static function decode_jwt( $jwt ) { + $parts = explode( '.', (string) $jwt ); + if ( 3 !== count( $parts ) ) { + return null; + } + + list( $header_b64, $payload_b64, $sig_b64 ) = $parts; + + $expected = self::base64url_encode( + hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, self::jwt_secret(), true ) + ); + + if ( ! hash_equals( $expected, $sig_b64 ) ) { + return null; + } + + $payload = json_decode( self::base64url_decode( $payload_b64 ) ); + if ( ! is_object( $payload ) || empty( $payload->sub ) ) { + return null; + } + + if ( empty( $payload->iss ) || 'tutor' !== $payload->iss ) { + return null; + } + + // Missing exp means unlimited; otherwise require a future expiration. + if ( isset( $payload->exp ) && (int) $payload->exp < time() ) { + return null; + } + + return $payload; + } + + /** + * Verify refresh JWT. Returns payload or null. + * + * @since 4.2.0 + * + * @param string $jwt Refresh token. + * + * @return object|null + */ + private static function verify_refresh_jwt( $jwt ) { + $payload = self::decode_jwt( $jwt ); + if ( ! $payload ) { + return null; + } + + if ( empty( $payload->typ ) || 'refresh' !== $payload->typ ) { + return null; + } + + $user_id = (int) $payload->sub; + $user = get_userdata( $user_id ); + if ( ! $user || ! $user->exists() ) { + return null; + } + + if ( function_exists( 'is_user_spammy' ) && is_user_spammy( $user ) ) { + return null; + } + + return $payload; + } + /** * Base64 URL encode (JWT-safe, no padding). * @@ -1762,7 +1829,12 @@ private static function get_request_headers() { } /** - * Issue opaque refresh token; store hash + kid in usermeta. + * Issue signed refresh JWT; store hash + kid in that user's usermeta. + * + * The JWT carries `sub` (user id) so refresh/logout can load a single + * usermeta row instead of scanning all users. + * + * @since 4.2.0 * * @param int $user_id user id. * @param int $kid API key usermeta id. @@ -1770,12 +1842,11 @@ private static function get_request_headers() { * @return string */ private static function issue_refresh_token( $user_id, $kid ) { - $token = bin2hex( random_bytes( 32 ) ); - $hash = hash( 'sha256', $token ); $list = self::get_refresh_token_list( $user_id ); $now = time(); $kid = absint( $kid ); $refresh_ttl = self::get_refresh_ttl(); + $exp = $refresh_ttl > static::TTL_UNLIMITED ? $now + $refresh_ttl : static::TTL_UNLIMITED; $list = array_values( array_filter( @@ -1790,9 +1861,24 @@ function ( $row ) use ( $now ) { ) ); + $claims = array( + 'sub' => (int) $user_id, + 'iat' => $now, + 'iss' => 'tutor', + 'typ' => 'refresh', + 'kid' => $kid, + ); + + if ( $exp > static::TTL_UNLIMITED ) { + $claims['exp'] = $exp; + } + + $token = self::encode_jwt( $claims ); + $hash = hash( 'sha256', $token ); + $list[] = array( 'hash' => $hash, - 'exp' => $refresh_ttl > static::TTL_UNLIMITED ? $now + $refresh_ttl : static::TTL_UNLIMITED, + 'exp' => $exp, 'kid' => $kid, ); @@ -1814,7 +1900,7 @@ private static function consume_refresh_token( $token ) { return null; } - self::delete_refresh_token( $token ); + self::delete_refresh_token( $token, $session['user_id'] ); return $session; } @@ -1833,6 +1919,8 @@ private static function find_user_id_by_refresh_token( $token ) { /** * Find refresh session (user id + kid) for a refresh token. * + * Verifies the signed refresh JWT, then checks the hash only for that user. + * * @since 4.2.0 * * @param string $token refresh token. @@ -1840,65 +1928,55 @@ private static function find_user_id_by_refresh_token( $token ) { * @return array{user_id:int,kid:int}|null */ private static function find_refresh_session( $token ) { - global $wpdb; - - $hash = hash( 'sha256', $token ); - $now = time(); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", - static::REFRESH_META_KEY - ) - ); - - if ( ! is_array( $rows ) ) { + $payload = self::verify_refresh_jwt( $token ); + if ( ! $payload ) { return null; } - foreach ( $rows as $row ) { - $list = json_decode( $row->meta_value, true ); - if ( ! is_array( $list ) ) { + $user_id = (int) $payload->sub; + $hash = hash( 'sha256', $token ); + $now = time(); + + foreach ( self::get_refresh_token_list( $user_id ) as $entry ) { + if ( empty( $entry['hash'] ) || ! isset( $entry['exp'] ) ) { + continue; + } + if ( ! hash_equals( $entry['hash'], $hash ) || ! self::is_refresh_exp_valid( $entry['exp'], $now ) ) { continue; } - foreach ( $list as $entry ) { - if ( empty( $entry['hash'] ) || ! isset( $entry['exp'] ) ) { - continue; - } - if ( ! hash_equals( $entry['hash'], $hash ) || ! self::is_refresh_exp_valid( $entry['exp'], $now ) ) { - continue; - } - - $kid = isset( $entry['kid'] ) ? absint( $entry['kid'] ) : 0; - if ( ! $kid ) { - return null; - } - return array( - 'user_id' => (int) $row->user_id, - 'kid' => $kid, - ); + $kid = isset( $entry['kid'] ) ? absint( $entry['kid'] ) : 0; + if ( ! $kid ) { + return null; } + + return array( + 'user_id' => $user_id, + 'kid' => $kid, + ); } return null; } /** - * Delete one refresh token. + * Delete one refresh token for a known (or JWT-derived) user. * - * @param string $token refresh token. + * @since 4.2.0 + * + * @param string $token Refresh token. + * @param int $user_id Optional. Skip JWT parse when already known. * * @return void */ - private static function delete_refresh_token( $token ) { - $user_id = self::find_user_id_by_refresh_token( $token ); + private static function delete_refresh_token( $token, $user_id = 0 ) { + $user_id = absint( $user_id ); if ( ! $user_id ) { - // Token may already be partially matched — scan by hash after consume path. - $hash = hash( 'sha256', $token ); - self::delete_refresh_hash_for_all_users( $hash ); - return; + $payload = self::verify_refresh_jwt( $token ); + if ( ! $payload ) { + return; + } + $user_id = (int) $payload->sub; } $hash = hash( 'sha256', $token ); @@ -1914,47 +1992,6 @@ function ( $row ) use ( $hash ) { update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); } - /** - * Remove a refresh hash across users (best-effort). - * - * @param string $hash sha256 hash. - * - * @return void - */ - private static function delete_refresh_hash_for_all_users( $hash ) { - global $wpdb; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT umeta_id, user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", - static::REFRESH_META_KEY - ) - ); - - if ( ! is_array( $rows ) ) { - return; - } - - foreach ( $rows as $row ) { - $list = json_decode( $row->meta_value, true ); - if ( ! is_array( $list ) ) { - continue; - } - $new = array_values( - array_filter( - $list, - function ( $entry ) use ( $hash ) { - return empty( $entry['hash'] ) || ! hash_equals( $entry['hash'], $hash ); - } - ) - ); - if ( count( $new ) !== count( $list ) ) { - update_user_meta( (int) $row->user_id, static::REFRESH_META_KEY, wp_json_encode( $new ) ); - } - } - } - /** * Delete all refresh tokens for a user. * From cfe608612735e4d097bbfc3117a1bf01afc0c51e Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 15:03:33 +0600 Subject: [PATCH 022/218] Fix: blocked injection from unescaped order by in get_all helper --- helpers/QueryHelper.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 868306c2f9..ca134904f3 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -852,14 +852,15 @@ public static function get_all( string $table, array $where, string $order_by, $ $where_clause = "WHERE {$where_clause}"; } - $limit = (int) sanitize_text_field( $limit ); - $limit_clause = ( -1 === $limit ) ? '' : 'LIMIT ' . $limit; + $limit = intval( $limit ); + $limit_clause = ( -1 === $limit ) ? '' : 'LIMIT ' . $limit; + $order_by_clause = self::prepare_order_clause( $order_by, $order ); //phpcs:disable $query = "SELECT * FROM {$table} {$where_clause} - ORDER BY {$order_by} {$order} + {$order_by_clause} {$limit_clause}"; return $wpdb->get_results( From c5a1512dffa1c2a612314d5b6c4ac8622184c41b Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 15:28:25 +0600 Subject: [PATCH 023/218] fix: prevent SQL injection from unprepared variables in update_where_in helper --- helpers/QueryHelper.php | 99 ++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index ca134904f3..30cdc2671b 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -887,24 +887,35 @@ public static function get_all( string $table, array $where, string $order_by, $ public static function update_where_in( string $table, array $data, string $where_in, string $where_col = 'ID' ) { global $wpdb; - $table = self::prepare_table_name( $table ); + $table = self::prepare_table_name( $table ); + $where_col = sanitize_key( $where_col ); if ( empty( $where_in ) || empty( $where_col ) ) { return false; } + + $in_array = is_array( $where_in ) ? $where_in : explode( ',', $where_in ); + $in_clause = self::prepare_in_clause( array_filter( array_map( 'trim', $in_array ) ) ); + + if ( empty( $in_clause ) ) { + return false; + } + $set_clause = self::prepare_set_clause( $data ); if ( '' === $set_clause ) { return false; } - // @codingStandardsIgnoreStart + + //phpcs:disable $query = $wpdb->prepare( "UPDATE {$table} {$set_clause} - WHERE $where_col IN ( $where_in ) + WHERE `{$where_col}` IN ( {$in_clause} ) AND 1 = %d ", 1 ); return $wpdb->query( $query ) ? true : false; + //phpcs:enable } /** @@ -917,10 +928,10 @@ public static function update_where_in( string $table, array $data, string $wher * @return string */ public static function prepare_set_clause( array $data ) { - $set = ''; + $set = ''; foreach ( $data as $key => $value ) { - if ( $key === array_key_first ( $data ) ) { - $set .= "SET "; + if ( array_key_first( $data ) === $key ) { + $set .= 'SET '; } // Multi dimension not allowed. if ( is_array( $value ) ) { @@ -928,13 +939,13 @@ public static function prepare_set_clause( array $data ) { } if ( is_null( $value ) ) { - $set .= "$key = null"; + $set .= "$key = null"; } else { $value = esc_sql( sanitize_text_field( $value ) ); - $set .= is_numeric( $value ) ? "$key = $value" : "$key = '" . $value ."'"; + $set .= is_numeric( $value ) ? "$key = $value" : "$key = '" . $value . "'"; } - - $set .= ","; + + $set .= ','; } return rtrim( $set, ',' ); } @@ -955,8 +966,8 @@ public static function prepare_value( $value ) { $escaped_value = $wpdb->prepare( '%d', $value ); } elseif ( is_float( $value ) ) { list( $whole, $decimal ) = explode( '.', $value ); - $expression = '%.'. strlen( $decimal ) . 'f'; - $escaped_value = $wpdb->prepare( $expression, $value ); + $expression = '%.' . strlen( $decimal ) . 'f'; + $escaped_value = $wpdb->prepare( $expression, $value ); } else { $escaped_value = $wpdb->prepare( '%s', $value ); } @@ -1040,8 +1051,8 @@ public static function get_joined_data( string $primary_table, array $joining_tables, array $select_columns, - array $where = [], - array $search = [], + array $where = array(), + array $search = array(), string $order_by = '', $limit = 10, $offset = 0, @@ -1071,7 +1082,7 @@ public static function get_joined_data( } $results = $wpdb->get_results( $query, $output ); - $has_records = is_array( $results ) && count( $results ); + $has_records = is_array( $results ) && count( $results ); $total_count = $has_records ? (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ) : 0; // Throw exception if error occurred. @@ -1101,12 +1112,14 @@ public static function get_joined_data( * @param string $count_column column name to count, default id. * * @return int + * + * @throws \Exception If an error occurred. */ - public static function get_count( $table, $where = [], $search = [], $count_column = 'id' ): int { + public static function get_count( $table, $where = array(), $search = array(), $count_column = 'id' ): int { global $wpdb; - $table = self::prepare_table_name( $table ); - $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); + $table = self::prepare_table_name( $table ); + $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); $count = $wpdb->get_var( "SELECT COUNT($count_column) @@ -1136,16 +1149,17 @@ public static function get_count( $table, $where = [], $search = [], $count_colu * @param string $count_column column name to count, default id. * * @return int + * + * @throws \Exception If an error occurred. */ - public static function get_joined_count(string $primary_table, array $joining_tables, array $where = [], array $search = [], string $count_column = '*'): int { + public static function get_joined_count( string $primary_table, array $joining_tables, array $where = array(), array $search = array(), string $count_column = '*' ): int { global $wpdb; - + $from_clause = self::prepare_table_name( $primary_table ); $join_clauses = self::prepare_join_clause( $joining_tables ); $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); - $count_query = " - SELECT COUNT($count_column) as total_count + $count_query = "SELECT COUNT($count_column) as total_count FROM {$from_clause} {$join_clauses} {$where_clause} @@ -1186,33 +1200,33 @@ public static function get_all_with_search( string $table, array $where, array $ $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); $order_by_clause = self::prepare_order_clause( $order_by, $order ); $limit_clause = self::prepare_limit_clause( $limit, $offset ); - + // If error occurred then throw new exception. if ( $wpdb->last_error ) { throw new \Exception( $wpdb->last_error ); } - + $query = "SELECT SQL_CALC_FOUND_ROWS * FROM {$table} {$where_clause} {$order_by_clause} {$limit_clause}"; - + $results = $wpdb->get_results( $query, $output ); $has_records = is_array( $results ) && count( $results ); $total_count = $has_records ? (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ) : 0; - + // If error occurred then throw new exception. if ( $wpdb->last_error ) { throw new \Exception( $wpdb->last_error ); } - + // Prepare response array. $response = array( 'results' => $results, 'total_count' => $total_count, ); - + return $response; } @@ -1261,7 +1275,7 @@ public static function get_period_clause( string $column, string $period = '' ) * * @return string */ - public static function get_last_query(){ + public static function get_last_query() { global $wpdb; return $wpdb->last_query; } @@ -1289,7 +1303,7 @@ public static function get_table_prefix() { */ public static function prepare_table_name( string $table_name ) { $table_prefix = self::get_table_prefix(); - if ( strpos( $table_name,$table_prefix ) !== 0 ) { + if ( strpos( $table_name, $table_prefix ) !== 0 ) { $table_name = $table_prefix . $table_name; } @@ -1301,9 +1315,9 @@ public static function prepare_table_name( string $table_name ) { * * @since 3.7.0 * - * @param string $table_name name of the database table (with prefix if needed). - * @param array $where associative array of WHERE conditions. - * @param callable|null $modifier optional callback to modify or exclude fields before insertion. + * @param string $table_name name of the database table (with prefix if needed). + * @param array $where associative array of WHERE conditions. + * @param callable|null $modifier optional callback to modify or exclude fields before insertion. * * @return int|WP_Error New row ID on success, or WP_Error on failure. */ @@ -1323,7 +1337,7 @@ public static function duplicate_row( $table_name, array $where, ?callable $modi return new \WP_Error( 'not_found', 'No matching row found to duplicate.' ); } - // Apply user-defined modifications (ex: remove ID, change field value) + // Apply user-defined modifications (ex: remove ID, change field value). if ( is_callable( $modifier ) ) { $row = call_user_func( $modifier, $row ); @@ -1332,14 +1346,14 @@ public static function duplicate_row( $table_name, array $where, ?callable $modi } } - // Prepare insert + // Prepare insert. $columns = array_keys( $row ); $placeholders = array_fill( 0, count( $columns ), '%s' ); $values = array_values( $row ); $insert_sql = $wpdb->prepare( - "INSERT INTO `$table_name` (`" . implode( '`, `', $columns ) . "`) - VALUES (" . implode( ', ', $placeholders ) . ")", + "INSERT INTO `$table_name` (`" . implode( '`, `', $columns ) . '`) + VALUES (' . implode( ', ', $placeholders ) . ')', ...$values ); @@ -1376,18 +1390,19 @@ public static function get_valid_sort_order( $order ) { * * @return array Returns an array of table columns and their details. */ - public static function get_table_schema( $table_name) { - + public static function get_table_schema( $table_name ) { + global $wpdb; + $table_name = self::prepare_table_name( $table_name ); + //phpcs:ignore $result = $wpdb->get_results( "DESCRIBE {$table_name}", ARRAY_A ); // If error occurred then throw new exception. - if ($wpdb->last_error) { - throw new \Exception($wpdb->last_error); + if ( $wpdb->last_error ) { + throw new \Exception( $wpdb->last_error ); } - return $result; } From 99df7ff9a562cf4c2d9b328d3cc8f3e3709cf838 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 15:36:39 +0600 Subject: [PATCH 024/218] wpcs issues fix --- helpers/QueryHelper.php | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 30cdc2671b..2e18928ec6 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -220,7 +220,7 @@ public static function insert_multiple_rows( $table, $request, $return_ids = fal // Trim trailing comma. $column_keys = rtrim( $column_keys, ',' ); - $column_values = $wpdb->prepare( $value_placeholder, $column_values ); // Escape values. + $column_values = $wpdb->prepare( $value_placeholder, $column_values ); //phpcs:ignore if ( $first_key === $k ) { $sql .= "INSERT INTO @@ -967,7 +967,7 @@ public static function prepare_value( $value ) { } elseif ( is_float( $value ) ) { list( $whole, $decimal ) = explode( '.', $value ); $expression = '%.' . strlen( $decimal ) . 'f'; - $escaped_value = $wpdb->prepare( $expression, $value ); + $escaped_value = $wpdb->prepare( $expression, $value );//phpcs:ignore } else { $escaped_value = $wpdb->prepare( '%s', $value ); } @@ -1002,7 +1002,7 @@ public static function table_exists( $table ) { $table = self::prepare_table_name( $table ); $sql = "SHOW TABLES LIKE '{$table}'"; - return $wpdb->get_var( $sql ) === $table; + return $wpdb->get_var( $sql ) === $table;//phpcs:ignore } /** @@ -1020,7 +1020,7 @@ public static function column_exist( $table, $column ) { $table = self::prepare_table_name( $table ); $sql = "SHOW COLUMNS FROM {$table} LIKE '{$column}'"; - return $wpdb->get_var( $sql ) === $column; + return $wpdb->get_var( $sql ) === $column;//phpcs:ignore } /** @@ -1078,10 +1078,10 @@ public static function get_joined_data( {$limit_clause}"; if ( $get_row ) { - return $wpdb->get_row( $query, $output ); + return $wpdb->get_row( $query, $output );//phpcs:ignore } - $results = $wpdb->get_results( $query, $output ); + $results = $wpdb->get_results( $query, $output );//phpcs:ignore $has_records = is_array( $results ) && count( $results ); $total_count = $has_records ? (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ) : 0; @@ -1121,11 +1121,8 @@ public static function get_count( $table, $where = array(), $search = array(), $ $table = self::prepare_table_name( $table ); $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); - $count = $wpdb->get_var( - "SELECT COUNT($count_column) - FROM $table - {$where_clause}" - ); + //phpcs:ignore + $count = $wpdb->get_var( "SELECT COUNT($count_column) FROM $table {$where_clause}" ); // If error occurred then throw new exception. if ( $wpdb->last_error ) { @@ -1165,7 +1162,7 @@ public static function get_joined_count( string $primary_table, array $joining_t {$where_clause} "; - $total_count = $wpdb->get_var( $count_query ); + $total_count = $wpdb->get_var( $count_query );//phpcs:ignore // If error occurred then throw new exception. if ( $wpdb->last_error ) { @@ -1212,7 +1209,7 @@ public static function get_all_with_search( string $table, array $where, array $ {$order_by_clause} {$limit_clause}"; - $results = $wpdb->get_results( $query, $output ); + $results = $wpdb->get_results( $query, $output );//phpcs:ignore $has_records = is_array( $results ) && count( $results ); $total_count = $has_records ? (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ) : 0; @@ -1329,9 +1326,11 @@ public static function duplicate_row( $table_name, array $where, ?callable $modi return new \WP_Error( 'missing_where', 'No WHERE condition provided.' ); } + //phpcs:disable $where_clause = self::prepare_where_clause( $where ); $sql = $wpdb->prepare( "SELECT * FROM `$table_name` WHERE {$where_clause} LIMIT %d", 1 ); $row = $wpdb->get_row( $sql, ARRAY_A ); + //phpcs:enable if ( ! $row ) { return new \WP_Error( 'not_found', 'No matching row found to duplicate.' ); @@ -1351,6 +1350,7 @@ public static function duplicate_row( $table_name, array $where, ?callable $modi $placeholders = array_fill( 0, count( $columns ), '%s' ); $values = array_values( $row ); + //phpcs:disable $insert_sql = $wpdb->prepare( "INSERT INTO `$table_name` (`" . implode( '`, `', $columns ) . '`) VALUES (' . implode( ', ', $placeholders ) . ')', @@ -1358,6 +1358,7 @@ public static function duplicate_row( $table_name, array $where, ?callable $modi ); $result = $wpdb->query( $insert_sql ); + //phpcs:enable if ( false === $result ) { return new \WP_Error( 'insert_failed', 'Failed to insert duplicate row.' ); From 2000b6260ccd1f83a0e292d48186fba8687ff3ac Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 15:42:05 +0600 Subject: [PATCH 025/218] fix: prevent SQL injection via unescaped column names in prepare_set_clause() --- helpers/QueryHelper.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 2e18928ec6..5d90f0f4d0 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -941,8 +941,9 @@ public static function prepare_set_clause( array $data ) { if ( is_null( $value ) ) { $set .= "$key = null"; } else { - $value = esc_sql( sanitize_text_field( $value ) ); - $set .= is_numeric( $value ) ? "$key = $value" : "$key = '" . $value . "'"; + $safe_key = '`' . sanitize_key( $key ) . '`'; + $set .= is_numeric( $value ) ? "$safe_key = $value" : "$safe_key = '" . $value . "'"; + } $set .= ','; From abbefd6d72abe6660243ce6e27a409867673c49d Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 15:46:38 +0600 Subject: [PATCH 026/218] fix: prevent SQL injection via --- helpers/QueryHelper.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 5d90f0f4d0..9c042954c2 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -1121,6 +1121,7 @@ public static function get_count( $table, $where = array(), $search = array(), $ $table = self::prepare_table_name( $table ); $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); + $count_column = '*' === trim( $count_column ) ? '*' : preg_replace( '/[^A-Za-z0-9_.]/', '', $count_column ); //phpcs:ignore $count = $wpdb->get_var( "SELECT COUNT($count_column) FROM $table {$where_clause}" ); @@ -1156,6 +1157,7 @@ public static function get_joined_count( string $primary_table, array $joining_t $from_clause = self::prepare_table_name( $primary_table ); $join_clauses = self::prepare_join_clause( $joining_tables ); $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); + $count_column = '*' === trim( $count_column ) ? '*' : preg_replace( '/[^A-Za-z0-9_.]/', '', $count_column ); $count_query = "SELECT COUNT($count_column) as total_count FROM {$from_clause} From 41ffaf6d55bd5d95f2717558f25e59b72ee81aba Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Wed, 30 Sep 2026 15:48:45 +0600 Subject: [PATCH 027/218] refactor(restapi): remove SSL requirement checks from authentication methods - Eliminated the SSL requirement checks from the `rest_login`, `rest_refresh`, and `rest_logout` methods in the RestAuth class to simplify the authentication flow. - Removed the private method `require_ssl_for_auth`, streamlining the code and enhancing maintainability. - This change focuses on improving the clarity of the authentication process while ensuring that the core functionality remains intact. --- restapi/RestAuth.php | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index d46e303f66..18c8a84794 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1239,11 +1239,6 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id * @return \WP_REST_Response|\WP_Error */ public static function rest_login( WP_REST_Request $request ) { - $ssl_error = self::require_ssl_for_auth(); - if ( is_wp_error( $ssl_error ) ) { - return $ssl_error; - } - $credentials = self::get_api_credentials_from_request(); if ( ! $credentials ) { return new \WP_Error( @@ -1312,11 +1307,6 @@ public static function rest_login( WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public static function rest_refresh( WP_REST_Request $request ) { - $ssl_error = self::require_ssl_for_auth(); - if ( is_wp_error( $ssl_error ) ) { - return $ssl_error; - } - $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); if ( '' === $refresh ) { return new \WP_Error( @@ -1356,11 +1346,6 @@ public static function rest_refresh( WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public static function rest_logout( WP_REST_Request $request ) { - $ssl_error = self::require_ssl_for_auth(); - if ( is_wp_error( $ssl_error ) ) { - return $ssl_error; - } - $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); $all = (bool) $request->get_param( 'all' ); @@ -2018,25 +2003,4 @@ private static function get_refresh_token_list( $user_id ) { $list = json_decode( $raw, true ); return is_array( $list ) ? $list : array(); } - - /** - * Require SSL for auth endpoints (except local). - * - * @return true|\WP_Error - */ - private static function require_ssl_for_auth() { - if ( is_ssl() ) { - return true; - } - - if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) { - return true; - } - - return new \WP_Error( - 'rest_ssl_required', - __( 'HTTPS is required for authentication.', 'tutor' ), - array( 'status' => 403 ) - ); - } } From 90e0054ee9e1d7fc0c6d455e1d32b11ede1f28de Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 16:13:28 +0600 Subject: [PATCH 028/218] fix: prevent SQL injection via array keys in prepare_like_clause() --- helpers/QueryHelper.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 9c042954c2..02f897171f 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -331,6 +331,21 @@ public static function is_support_operator( $operator ) { ); } + /** + * Check if a string is a valid column name. + * + * @since 4.1.1 + * + * @param string $column_name The column name to check. + * Allowed: foo, foo_bar, _foo, foo.bar etc. + * + * @return bool True if the column name is valid, false otherwise. + */ + public static function is_valid_column_name( $column_name ) { + return is_string( $column_name ) + && (bool) preg_match( '/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/', $column_name ); + } + /** * Prepare where clause string * @@ -454,8 +469,15 @@ public static function prepare_like_clause( array $where, $relation = 'OR' ) { $like_conditions = array(); foreach ( $where as $column_name => $term ) { - //phpcs:ignore - $like_conditions[] = $wpdb->prepare( "$column_name LIKE %s", '%' . $wpdb->esc_like( $term ) . '%' ); + if ( ! self::is_valid_column_name( $column_name ) ) { + continue; + } + + $like_conditions[] = $wpdb->prepare( + //phpcs:ignore + sprintf( '`%s` LIKE %%s', $column_name ), + '%' . $wpdb->esc_like( $term ) . '%' + ); } $where_clause = implode( ' OR ', $like_conditions ); From 4cc6c28e35f95a1462aad2f3d52334042d6664bb Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 16:45:58 +0600 Subject: [PATCH 029/218] feat: add quote_sql_identifier method to safely quote SQL identifiers --- helpers/QueryHelper.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 02f897171f..12bbde476b 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -346,6 +346,32 @@ public static function is_valid_column_name( $column_name ) { && (bool) preg_match( '/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/', $column_name ); } + /** + * Safely quotes a SQL identifier or qualified identifier. + * + * Supports simple identifiers (e.g. `foo`) and qualified identifiers + * (e.g. `u.ID`). Each identifier segment is validated before being + * wrapped in backticks. + * + * @since 4.1.1 + * + * @param string $identifier SQL identifier to quote. + * @return string Quoted SQL identifier, or an empty string if invalid. + */ + public static function quote_sql_identifier( $identifier ) { + $parts = explode( '.', $identifier ); + + foreach ( $parts as &$part ) { + if ( ! preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $part ) ) { + return ''; + } + + $part = '`' . $part . '`'; + } + + return implode( '.', $parts ); + } + /** * Prepare where clause string * @@ -473,9 +499,10 @@ public static function prepare_like_clause( array $where, $relation = 'OR' ) { continue; } + $quoted_column = self::quote_sql_identifier( $column_name ); $like_conditions[] = $wpdb->prepare( //phpcs:ignore - sprintf( '`%s` LIKE %%s', $column_name ), + sprintf( '%s LIKE %%s', $quoted_column ), '%' . $wpdb->esc_like( $term ) . '%' ); } From 3994fc007e25d5e4c774074310d867855febac98 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 30 Sep 2026 17:32:05 +0600 Subject: [PATCH 030/218] fix: wrap table names in backticks in QueryHelper SQL queries --- helpers/QueryHelper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 12bbde476b..0e302d503f 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -1069,7 +1069,7 @@ public static function column_exist( $table, $column ) { global $wpdb; $table = self::prepare_table_name( $table ); - $sql = "SHOW COLUMNS FROM {$table} LIKE '{$column}'"; + $sql = "SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"; return $wpdb->get_var( $sql ) === $column;//phpcs:ignore } @@ -1449,7 +1449,7 @@ public static function get_table_schema( $table_name ) { $table_name = self::prepare_table_name( $table_name ); //phpcs:ignore - $result = $wpdb->get_results( "DESCRIBE {$table_name}", ARRAY_A ); + $result = $wpdb->get_results( "DESCRIBE `{$table_name}`", ARRAY_A ); // If error occurred then throw new exception. if ( $wpdb->last_error ) { From a42759928503beb322eafb0d88bcc05987b4336e Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 11:42:35 +0600 Subject: [PATCH 031/218] docs: add blank line to quote_sql_identifier docblock --- helpers/QueryHelper.php | 1 + 1 file changed, 1 insertion(+) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 0e302d503f..210eee323f 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -356,6 +356,7 @@ public static function is_valid_column_name( $column_name ) { * @since 4.1.1 * * @param string $identifier SQL identifier to quote. + * * @return string Quoted SQL identifier, or an empty string if invalid. */ public static function quote_sql_identifier( $identifier ) { From 9a4ee06419c65b3bcb2a52b4e42385e15eb1cd9d Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 11:48:29 +0600 Subject: [PATCH 032/218] fix: use quote_sql_identifier for where_col in QueryHelper --- helpers/QueryHelper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 210eee323f..0dae1f7d76 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -938,7 +938,7 @@ public static function update_where_in( string $table, array $data, string $wher global $wpdb; $table = self::prepare_table_name( $table ); - $where_col = sanitize_key( $where_col ); + $where_col = self::quote_sql_identifier( $where_col ); if ( empty( $where_in ) || empty( $where_col ) ) { return false; } @@ -959,7 +959,7 @@ public static function update_where_in( string $table, array $data, string $wher $query = $wpdb->prepare( "UPDATE {$table} {$set_clause} - WHERE `{$where_col}` IN ( {$in_clause} ) + WHERE {$where_col} IN ( {$in_clause} ) AND 1 = %d ", 1 From a47077a908824b98ba58ce8deb6c0aadb81eb9e9 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 12:15:54 +0600 Subject: [PATCH 033/218] refactor: improve SQL identifier validation and quoting in QueryHelper --- helpers/QueryHelper.php | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 0dae1f7d76..6d0b913e56 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -346,6 +346,25 @@ public static function is_valid_column_name( $column_name ) { && (bool) preg_match( '/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/', $column_name ); } + /** + * Validate a single identifier part. + * + * @since 4.1.1 + * + * @param string $part Raw identifier part, e.g. 'post_id'. + * + * @return string The same part if valid, or '' if invalid. + */ + public static function prepare_identifier( $part ) { + $part = (string) $part; + + if ( ! preg_match( '/^[A-Za-z_][A-Za-z0-9_]*\z/', $part ) ) { + return ''; + } + + return $part; + } + /** * Safely quotes a SQL identifier or qualified identifier. * @@ -360,17 +379,19 @@ public static function is_valid_column_name( $column_name ) { * @return string Quoted SQL identifier, or an empty string if invalid. */ public static function quote_sql_identifier( $identifier ) { - $parts = explode( '.', $identifier ); + $quoted = array(); + + foreach ( explode( '.', (string) $identifier ) as $part ) { + $part = self::prepare_identifier( $part ); - foreach ( $parts as &$part ) { - if ( ! preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $part ) ) { + if ( '' === $part ) { return ''; } - $part = '`' . $part . '`'; + $quoted[] = '`' . $part . '`'; } - return implode( '.', $parts ); + return implode( '.', $quoted ); } /** @@ -1171,7 +1192,7 @@ public static function get_count( $table, $where = array(), $search = array(), $ $table = self::prepare_table_name( $table ); $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); - $count_column = '*' === trim( $count_column ) ? '*' : preg_replace( '/[^A-Za-z0-9_.]/', '', $count_column ); + $count_column = '*' === $count_column ? '*' : self::quote_sql_identifier( $count_column ); //phpcs:ignore $count = $wpdb->get_var( "SELECT COUNT($count_column) FROM $table {$where_clause}" ); @@ -1207,7 +1228,7 @@ public static function get_joined_count( string $primary_table, array $joining_t $from_clause = self::prepare_table_name( $primary_table ); $join_clauses = self::prepare_join_clause( $joining_tables ); $where_clause = self::prepare_where_search_clause( $where, $search, 'AND' ); - $count_column = '*' === trim( $count_column ) ? '*' : preg_replace( '/[^A-Za-z0-9_.]/', '', $count_column ); + $count_column = '*' === $count_column ? '*' : self::quote_sql_identifier( $count_column ); $count_query = "SELECT COUNT($count_column) as total_count FROM {$from_clause} From 5eae3ea897c7318393e0887aa224c239e9e6ee5b Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 12:44:48 +0600 Subject: [PATCH 034/218] fix: harden SQL query generation against injection by validating and quoting column names and identifiers --- helpers/QueryHelper.php | 69 ++-- tests/phpunit/QueryHelperTest.php | 584 +++++++++++++++++++++++++++++- 2 files changed, 624 insertions(+), 29 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 6d0b913e56..809cf5e1a8 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -279,6 +279,12 @@ public static function insert_multiple_rows( $table, $request, $return_ids = fal public static function make_clause( array $where ) { list ( $field, $operator, $value ) = $where; + // Validate and quote the field/column name to prevent SQL injection. + if ( ! self::is_valid_column_name( $field ) ) { + return '1=0'; + } + + $quoted_field = self::quote_sql_identifier( $field ); $upper_operator = strtoupper( $operator ); if ( in_array( $upper_operator, array( 'IN', 'NOT IN' ), true ) ) { @@ -292,7 +298,7 @@ public static function make_clause( array $where ) { $value = self::prepare_value( $value ); } - return "{$field} {$upper_operator} {$value}"; + return "{$quoted_field} {$upper_operator} {$value}"; } /** @@ -707,7 +713,7 @@ protected static function prepare_where_search_clause( $where = array(), $search * * @return string */ - protected static function prepare_order_clause( $orderby = '', $order = 'DESC' ) { + public static function prepare_order_clause( $orderby = '', $order = 'DESC' ) { if ( empty( $orderby ) ) { return ''; } @@ -731,7 +737,7 @@ protected static function prepare_order_clause( $orderby = '', $order = 'DESC' ) * * @return string */ - protected static function prepare_limit_clause( $limit = 0, $offset = 0 ) { + public static function prepare_limit_clause( $limit = 0, $offset = 0 ) { if ( $limit < 1 || $offset < 0 ) { return ''; } @@ -779,15 +785,18 @@ public static function query( $table, $args = array() ) { $output = $args['output'] ?? 'OBJECT'; // Primary table. - $table = self::prepare_table_name( $table ); - $alias = $args['alias'] ?? 'main'; - $table_with_alias = "{$table} AS {$alias}"; + $table = self::prepare_table_name( $table ); + $alias = self::prepare_identifier( $args['alias'] ?? 'main' ); + if ( '' === $alias ) { + $alias = 'main'; + } + $table_with_alias = "{$table} AS `{$alias}`"; // Build clauses. $select_clause = self::prepare_select_clause( $args['select'] ?? '' ); $join_clause = self::prepare_join_clause( $args['joins'] ?? array() ); $where_clause = self::prepare_where_search_clause( $args['where'] ?? array(), $args['search'] ?? array() ); - $groupby_clause = empty( $args['groupby'] ) ? '' : 'GROUP BY ' . $args['groupby']; + $groupby_clause = empty( $args['groupby'] ) || ! self::is_valid_column_name( $args['groupby'] ) ? '' : 'GROUP BY ' . self::quote_sql_identifier( $args['groupby'] ); $having_clause = empty( $args['having'] ) ? '' : 'HAVING ' . $args['having']; $order_by_clause = self::prepare_order_clause( $args['orderby'] ?? '', $args['order'] ?? 'DESC' ); @@ -1009,12 +1018,13 @@ public static function prepare_set_clause( array $data ) { continue; } + $safe_key = '`' . sanitize_key( $key ) . '`'; + if ( is_null( $value ) ) { - $set .= "$key = null"; + $set .= "{$safe_key} = null"; } else { - $safe_key = '`' . sanitize_key( $key ) . '`'; - $set .= is_numeric( $value ) ? "$safe_key = $value" : "$safe_key = '" . $value . "'"; - + $prepared_value = self::prepare_value( $value ); + $set .= "{$safe_key} = {$prepared_value}"; } $set .= ','; @@ -1090,9 +1100,14 @@ public static function table_exists( $table ) { public static function column_exist( $table, $column ) { global $wpdb; + $column = self::prepare_identifier( $column ); + if ( '' === $column ) { + return false; + } + $table = self::prepare_table_name( $table ); - $sql = "SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"; - return $wpdb->get_var( $sql ) === $column;//phpcs:ignore + $sql = $wpdb->prepare( "SHOW COLUMNS FROM `{$table}` LIKE %s", $column ); //phpcs:ignore + return $wpdb->get_var( $sql ) === $column; //phpcs:ignore } /** @@ -1312,25 +1327,31 @@ public static function get_all_with_search( string $table, array $where, array $ * @return string */ public static function get_period_clause( string $column, string $period = '' ) { + // Validate the column name to prevent SQL injection. + if ( ! self::is_valid_column_name( $column ) ) { + return ''; + } + + $quoted_column = self::quote_sql_identifier( $column ); $period_clause = ''; switch ( $period ) { case 'today': - $period_clause = "AND DATE($column) = CURDATE()"; + $period_clause = "AND DATE({$quoted_column}) = CURDATE()"; break; case 'monthly': - $period_clause = "AND MONTH($column) = MONTH(CURDATE()) AND YEAR($column) = YEAR(CURDATE())"; + $period_clause = "AND MONTH({$quoted_column}) = MONTH(CURDATE()) AND YEAR({$quoted_column}) = YEAR(CURDATE())"; break; case 'yearly': - $period_clause = "AND YEAR($column) = YEAR(CURDATE())"; + $period_clause = "AND YEAR({$quoted_column}) = YEAR(CURDATE())"; break; case 'last30days': - $period_clause = "AND DATE($column) BETWEEN DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND CURDATE()"; + $period_clause = "AND DATE({$quoted_column}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND CURDATE()"; break; case 'last90days': - $period_clause = "AND DATE($column) BETWEEN DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND CURDATE()"; + $period_clause = "AND DATE({$quoted_column}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND CURDATE()"; break; case 'last365days': - $period_clause = "AND DATE($column) BETWEEN DATE_SUB(CURDATE(), INTERVAL 365 DAY) AND CURDATE()"; + $period_clause = "AND DATE({$quoted_column}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 365 DAY) AND CURDATE()"; break; default: break; @@ -1419,8 +1440,14 @@ public static function duplicate_row( $table_name, array $where, ?callable $modi } } - // Prepare insert. - $columns = array_keys( $row ); + // Prepare insert — validate column names to prevent injection via modifier callback. + $columns = array_keys( $row ); + foreach ( $columns as $col ) { + if ( '' === self::prepare_identifier( $col ) ) { + return new \WP_Error( 'invalid_column', 'Invalid column name: ' . sanitize_text_field( $col ) ); + } + } + $placeholders = array_fill( 0, count( $columns ), '%s' ); $values = array_values( $row ); diff --git a/tests/phpunit/QueryHelperTest.php b/tests/phpunit/QueryHelperTest.php index bcb608f8cb..297e904b6e 100644 --- a/tests/phpunit/QueryHelperTest.php +++ b/tests/phpunit/QueryHelperTest.php @@ -12,6 +12,8 @@ /** * QueryHelper utility methods testing + * + * Run test by: vendor/bin/phpunit --filter=QueryHelperTest */ class QueryHelperTest extends \WP_UnitTestCase { @@ -24,7 +26,7 @@ public function test_prepare_set_clause() { // Single elements. $array1 = array( 'id' => 1 ); $actual1 = QueryHelper::prepare_set_clause( $array1 ); - $expect = 'SET id = 1'; + $expect = 'SET `id` = 1'; // Multiple elements. $array2 = array( @@ -33,7 +35,7 @@ public function test_prepare_set_clause() { 'status' => 'publish', ); $actual2 = QueryHelper::prepare_set_clause( $array2 ); - $expect2 = "SET id = 1,title = 'title',status = 'publish'"; + $expect2 = "SET `id` = 1,`title` = 'title',`status` = 'publish'"; // Multi-dimension array. $array3 = array( @@ -49,7 +51,7 @@ public function test_prepare_set_clause() { $actual3 = QueryHelper::prepare_set_clause( $array3 ); // Since multi-dimension is not allowed post_parent will be omitted. - $expect3 = "SET id = 1,title = 'title',status = 'publish'"; + $expect3 = "SET `id` = 1,`title` = 'title',`status` = 'publish'"; $array4 = array(); $actual4 = QueryHelper::prepare_set_clause( $array4 ); @@ -61,6 +63,36 @@ public function test_prepare_set_clause() { $this->assertSame( $expect4, trim( $actual4 ) ); } + /** + * Test prepare_set_clause with null values. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_set_clause_null_value() { + $data = array( 'deleted_at' => null ); + $actual = QueryHelper::prepare_set_clause( $data ); + $this->assertSame( 'SET `deleted_at` = null', trim( $actual ) ); + } + + /** + * Test that prepare_set_clause escapes string values properly + * to prevent SQL injection. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_set_clause_escapes_special_characters() { + $data = array( 'name' => "O'Brien" ); + $actual = QueryHelper::prepare_set_clause( $data ); + + // The value must be properly escaped — no raw single quote breakout. + $this->assertStringNotContainsString( "O'Brien", $actual ); + $this->assertStringContainsString( '`name`', $actual ); + } + /** * Test QueryHelper::prepare_where_clause() * @@ -75,7 +107,7 @@ public function test_prepare_where_clause() { 'height' => array( '>=', '5feet7inch' ), 'hobbies' => array( 'coding', 'biking', 'swimming' ), ); - $expect1 = "age = 18 AND height >= '5feet7inch' AND hobbies IN ('coding','biking','swimming')"; + $expect1 = "`age` = 18 AND `height` >= '5feet7inch' AND `hobbies` IN ('coding','biking','swimming')"; $actual1 = QueryHelper::prepare_where_clause( $test1 ); $test2 = array( @@ -83,7 +115,7 @@ public function test_prepare_where_clause() { 'name' => array( 'NOT BETWEEN', array( 'b', 'c' ) ), ); - $expect2 = "salary BETWEEN 10 AND 20 AND name NOT BETWEEN 'b' AND 'c'"; + $expect2 = "`salary` BETWEEN 10 AND 20 AND `name` NOT BETWEEN 'b' AND 'c'"; $actual2 = QueryHelper::prepare_where_clause( $test2 ); $test3 = array( @@ -91,7 +123,7 @@ public function test_prepare_where_clause() { 'age' => array( 'NOT IN', array( 18, 19, 20 ) ), ); - $expect3 = "name LIKE 'test' AND age NOT IN (18,19,20)"; + $expect3 = "`name` LIKE 'test' AND `age` NOT IN (18,19,20)"; $actual3 = QueryHelper::prepare_where_clause( $test3 ); $test4 = array( @@ -99,7 +131,7 @@ public function test_prepare_where_clause() { 'age' => array( 'IS NOT', 'NULL' ), ); - $expect4 = 'name IS NULL AND age IS NOT NULL'; + $expect4 = '`name` IS NULL AND `age` IS NOT NULL'; $actual4 = QueryHelper::prepare_where_clause( $test4 ); $this->assertEquals( $expect1, trim( $actual1 ) ); @@ -147,7 +179,7 @@ public function test_prepare_raw_query() { $expect_1 = "username = 'admin'"; $expect_2 = 'age >= 20'; - $expect_3 = 'id BETWEEN 10 AND 20 AND DATE(value) = CAST(value as date) AND id = 10'; + $expect_3 = '`id` BETWEEN 10 AND 20 AND DATE(value) = CAST(value as date) AND id = 10'; $this->assertEquals( $expect_1, QueryHelper::prepare_where_clause( $case_1 ) ); $this->assertEquals( $expect_2, QueryHelper::prepare_where_clause( $case_2 ) ); @@ -170,4 +202,540 @@ public function test_prepare_in_clause() { $this->assertEquals( "'jhon',3,4.55,'adam'", QueryHelper::prepare_in_clause( array( 'jhon', 3, 4.55, 'adam' ) ) ); $this->assertEquals( '2,3,5.996', QueryHelper::prepare_in_clause( array( 2, 3, 5.996 ) ) ); } + + /** + * Test prepare_in_clause with an empty array. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_in_clause_empty_array() { + $this->assertSame( '', QueryHelper::prepare_in_clause( array() ) ); + } + + /** + * Test prepare_in_clause escapes SQL injection strings. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_in_clause_escapes_injection() { + $result = QueryHelper::prepare_in_clause( array( "'; DROP TABLE users; --" ) ); + // The single quote must be escaped (backslash-escaped by $wpdb->prepare). + $this->assertStringContainsString( "\\'", $result ); + // The result must be a single quoted string, not a breakout. + $this->assertStringStartsWith( "'", $result ); + $this->assertStringEndsWith( "'", $result ); + } + + // ─── is_valid_column_name ─────────────────────────────────────────────── + + /** + * Test is_valid_column_name with valid column names. + * + * @since 4.1.1 + * + * @return void + */ + public function test_is_valid_column_name_valid() { + $this->assertTrue( QueryHelper::is_valid_column_name( 'id' ) ); + $this->assertTrue( QueryHelper::is_valid_column_name( 'post_title' ) ); + $this->assertTrue( QueryHelper::is_valid_column_name( '_private' ) ); + $this->assertTrue( QueryHelper::is_valid_column_name( 'u.ID' ) ); + $this->assertTrue( QueryHelper::is_valid_column_name( 'table_name.column_name' ) ); + $this->assertTrue( QueryHelper::is_valid_column_name( 'A' ) ); + } + + /** + * Test is_valid_column_name with invalid column names. + * + * @since 4.1.1 + * + * @return void + */ + public function test_is_valid_column_name_invalid() { + $this->assertFalse( QueryHelper::is_valid_column_name( '' ) ); + $this->assertFalse( QueryHelper::is_valid_column_name( '1column' ) ); + $this->assertFalse( QueryHelper::is_valid_column_name( 'col name' ) ); + $this->assertFalse( QueryHelper::is_valid_column_name( "col'; DROP TABLE" ) ); + $this->assertFalse( QueryHelper::is_valid_column_name( 'col--comment' ) ); + $this->assertFalse( QueryHelper::is_valid_column_name( 'a.b.c' ) ); // Too many dots. + $this->assertFalse( QueryHelper::is_valid_column_name( '.col' ) ); + $this->assertFalse( QueryHelper::is_valid_column_name( 'col.' ) ); + } + + // ─── prepare_identifier ───────────────────────────────────────────────── + + /** + * Test prepare_identifier with valid identifiers. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_identifier_valid() { + $this->assertSame( 'id', QueryHelper::prepare_identifier( 'id' ) ); + $this->assertSame( 'post_title', QueryHelper::prepare_identifier( 'post_title' ) ); + $this->assertSame( '_meta', QueryHelper::prepare_identifier( '_meta' ) ); + $this->assertSame( 'Col123', QueryHelper::prepare_identifier( 'Col123' ) ); + } + + /** + * Test prepare_identifier with invalid identifiers. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_identifier_invalid() { + $this->assertSame( '', QueryHelper::prepare_identifier( '' ) ); + $this->assertSame( '', QueryHelper::prepare_identifier( '123col' ) ); + $this->assertSame( '', QueryHelper::prepare_identifier( 'col name' ) ); + $this->assertSame( '', QueryHelper::prepare_identifier( 'col;DROP' ) ); + $this->assertSame( '', QueryHelper::prepare_identifier( 'table.col' ) ); // Dots not allowed in single identifier. + $this->assertSame( '', QueryHelper::prepare_identifier( "col'" ) ); + } + + // ─── quote_sql_identifier ─────────────────────────────────────────────── + + /** + * Test quote_sql_identifier with valid identifiers. + * + * @since 4.1.1 + * + * @return void + */ + public function test_quote_sql_identifier_valid() { + $this->assertSame( '`id`', QueryHelper::quote_sql_identifier( 'id' ) ); + $this->assertSame( '`u`.`ID`', QueryHelper::quote_sql_identifier( 'u.ID' ) ); + $this->assertSame( '`tbl`.`col_name`', QueryHelper::quote_sql_identifier( 'tbl.col_name' ) ); + } + + /** + * Test quote_sql_identifier with invalid identifiers. + * + * @since 4.1.1 + * + * @return void + */ + public function test_quote_sql_identifier_invalid() { + $this->assertSame( '', QueryHelper::quote_sql_identifier( '' ) ); + $this->assertSame( '', QueryHelper::quote_sql_identifier( "'; DROP TABLE" ) ); + $this->assertSame( '', QueryHelper::quote_sql_identifier( '1bad' ) ); + $this->assertSame( '', QueryHelper::quote_sql_identifier( '.col' ) ); + } + + // ─── is_support_operator ──────────────────────────────────────────────── + + /** + * Test is_support_operator accepts all supported operators. + * + * @since 4.1.1 + * + * @return void + */ + public function test_is_support_operator_valid() { + $supported = array( '=', '!=', '<>', '>', '<', '>=', '<=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS', 'IS NOT', 'BETWEEN', 'NOT BETWEEN', 'RAW' ); + foreach ( $supported as $op ) { + $this->assertTrue( QueryHelper::is_support_operator( $op ), "Operator '{$op}' should be supported" ); + } + // Case-insensitive. + $this->assertTrue( QueryHelper::is_support_operator( 'like' ) ); + $this->assertTrue( QueryHelper::is_support_operator( 'Between' ) ); + } + + /** + * Test is_support_operator rejects unsupported operators. + * + * @since 4.1.1 + * + * @return void + */ + public function test_is_support_operator_invalid() { + $this->assertFalse( QueryHelper::is_support_operator( 'DROP' ) ); + $this->assertFalse( QueryHelper::is_support_operator( 'UNION' ) ); + $this->assertFalse( QueryHelper::is_support_operator( '' ) ); + $this->assertFalse( QueryHelper::is_support_operator( '||' ) ); + } + + // ─── make_clause ──────────────────────────────────────────────────────── + + /** + * Test make_clause with basic equality. + * + * @since 4.1.1 + * + * @return void + */ + public function test_make_clause_equality() { + $result = QueryHelper::make_clause( array( 'status', '=', 'active' ) ); + $this->assertSame( "`status` = 'active'", $result ); + } + + /** + * Test make_clause with IN operator. + * + * @since 4.1.1 + * + * @return void + */ + public function test_make_clause_in() { + $result = QueryHelper::make_clause( array( 'id', 'IN', array( 1, 2, 3 ) ) ); + $this->assertSame( '`id` IN (1,2,3)', $result ); + } + + /** + * Test make_clause with NULL value. + * + * @since 4.1.1 + * + * @return void + */ + public function test_make_clause_null() { + $result = QueryHelper::make_clause( array( 'deleted_at', 'IS', 'NULL' ) ); + $this->assertSame( '`deleted_at` IS NULL', $result ); + } + + /** + * Test make_clause rejects injection in field name. + * + * @since 4.1.1 + * + * @return void + */ + public function test_make_clause_rejects_injection_in_field() { + $result = QueryHelper::make_clause( array( "id; DROP TABLE users", '=', 1 ) ); + $this->assertSame( '1=0', $result ); + } + + /** + * Test make_clause with qualified column name (table.column). + * + * @since 4.1.1 + * + * @return void + */ + public function test_make_clause_qualified_column() { + $result = QueryHelper::make_clause( array( 'u.status', '=', 'active' ) ); + $this->assertSame( "`u`.`status` = 'active'", $result ); + } + + // ─── prepare_like_clause ──────────────────────────────────────────────── + + /** + * Test prepare_like_clause with valid columns. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_like_clause_valid() { + $result = QueryHelper::prepare_like_clause( array( 'name' => 'test' ) ); + $this->assertStringContainsString( '`name`', $result ); + $this->assertStringContainsString( 'LIKE', $result ); + $this->assertStringContainsString( 'test', $result ); + } + + /** + * Test prepare_like_clause skips invalid column names. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_like_clause_skips_invalid_columns() { + $result = QueryHelper::prepare_like_clause( array( "invalid; DROP" => 'test' ) ); + $this->assertSame( '', $result ); + } + + /** + * Test prepare_like_clause with multiple columns. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_like_clause_multiple_columns() { + $result = QueryHelper::prepare_like_clause( + array( + 'name' => 'john', + 'email' => 'john', + ) + ); + $this->assertStringContainsString( '`name`', $result ); + $this->assertStringContainsString( '`email`', $result ); + $this->assertStringContainsString( ' OR ', $result ); + } + + // ─── prepare_order_clause ─────────────────────────────────────────────── + + /** + * Test prepare_order_clause with valid inputs. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_order_clause_valid() { + $this->assertSame( 'ORDER BY id DESC', QueryHelper::prepare_order_clause( 'id', 'DESC' ) ); + $this->assertSame( 'ORDER BY created_at ASC', QueryHelper::prepare_order_clause( 'created_at', 'ASC' ) ); + $this->assertSame( 'ORDER BY u.name DESC', QueryHelper::prepare_order_clause( 'u.name', 'DESC' ) ); + } + + /** + * Test prepare_order_clause returns empty string for empty or invalid input. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_order_clause_empty_and_invalid() { + $this->assertSame( '', QueryHelper::prepare_order_clause( '' ) ); + $this->assertSame( '', QueryHelper::prepare_order_clause( 'id; DROP TABLE' ) ); + $this->assertSame( '', QueryHelper::prepare_order_clause( "col'bad" ) ); + } + + /** + * Test prepare_order_clause defaults invalid order to DESC. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_order_clause_defaults_order() { + $this->assertSame( 'ORDER BY id DESC', QueryHelper::prepare_order_clause( 'id', 'INVALID' ) ); + $this->assertSame( 'ORDER BY id DESC', QueryHelper::prepare_order_clause( 'id', 'desc' ) ); + } + + // ─── prepare_limit_clause ─────────────────────────────────────────────── + + /** + * Test prepare_limit_clause with valid inputs. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_limit_clause_valid() { + $this->assertSame( 'LIMIT 10 OFFSET 0', QueryHelper::prepare_limit_clause( 10, 0 ) ); + $this->assertSame( 'LIMIT 25 OFFSET 50', QueryHelper::prepare_limit_clause( 25, 50 ) ); + } + + /** + * Test prepare_limit_clause returns empty for invalid values. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_limit_clause_invalid() { + $this->assertSame( '', QueryHelper::prepare_limit_clause( 0, 0 ) ); + $this->assertSame( '', QueryHelper::prepare_limit_clause( -1, 0 ) ); + $this->assertSame( '', QueryHelper::prepare_limit_clause( 10, -1 ) ); + } + + // ─── prepare_value ────────────────────────────────────────────────────── + + /** + * Test prepare_value with integer values. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_value_integer() { + $this->assertEquals( '42', QueryHelper::prepare_value( 42 ) ); + $this->assertEquals( '0', QueryHelper::prepare_value( 0 ) ); + $this->assertEquals( '-5', QueryHelper::prepare_value( -5 ) ); + } + + /** + * Test prepare_value with float values. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_value_float() { + $this->assertEquals( '3.14', QueryHelper::prepare_value( 3.14 ) ); + $this->assertEquals( '0.5', QueryHelper::prepare_value( 0.5 ) ); + $this->assertEquals( '1.25', QueryHelper::prepare_value( 1.25 ) ); + } + + /** + * Test prepare_value with string values. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_value_string() { + $result = QueryHelper::prepare_value( 'hello' ); + $this->assertSame( "'hello'", $result ); + } + + /** + * Test prepare_value escapes SQL-special characters. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_value_escapes_special() { + $result = QueryHelper::prepare_value( "test'value" ); + // Must not contain an unescaped single quote. + $this->assertStringNotContainsString( "test'value", $result ); + } + + // ─── prepare_raw_query ────────────────────────────────────────────────── + + /** + * Test prepare_raw_query rejects unsafe SQL characters. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_raw_query_rejects_unsafe() { + // Semicolons are not allowed. + $this->assertSame( '', QueryHelper::prepare_raw_query( 'id = 1; DROP TABLE users', array() ) ); + + // Backticks are not allowed. + $this->assertSame( '', QueryHelper::prepare_raw_query( '`id` = 1', array() ) ); + + // Block comment syntax is not allowed. + $this->assertSame( '', QueryHelper::prepare_raw_query( 'id = 1 /* comment */', array() ) ); + } + + /** + * Test prepare_raw_query without parameters returns raw query as-is. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_raw_query_no_params() { + $this->assertSame( 'age = 18', QueryHelper::prepare_raw_query( 'age = 18', array() ) ); + } + + // ─── get_period_clause ────────────────────────────────────────────────── + + /** + * Test get_period_clause with valid column and periods. + * + * @since 4.1.1 + * + * @return void + */ + public function test_get_period_clause_valid() { + $result = QueryHelper::get_period_clause( 'o.created_at', 'today' ); + $this->assertStringContainsString( '`o`.`created_at`', $result ); + $this->assertStringContainsString( 'CURDATE()', $result ); + + $result = QueryHelper::get_period_clause( 'created_at', 'monthly' ); + $this->assertStringContainsString( 'MONTH(`created_at`)', $result ); + $this->assertStringContainsString( 'YEAR(`created_at`)', $result ); + + $result = QueryHelper::get_period_clause( 'created_at', 'yearly' ); + $this->assertStringContainsString( 'YEAR(`created_at`)', $result ); + + $result = QueryHelper::get_period_clause( 'created_at', 'last30days' ); + $this->assertStringContainsString( 'INTERVAL 30 DAY', $result ); + + $result = QueryHelper::get_period_clause( 'created_at', 'last90days' ); + $this->assertStringContainsString( 'INTERVAL 90 DAY', $result ); + + $result = QueryHelper::get_period_clause( 'created_at', 'last365days' ); + $this->assertStringContainsString( 'INTERVAL 365 DAY', $result ); + } + + /** + * Test get_period_clause returns empty for unknown period. + * + * @since 4.1.1 + * + * @return void + */ + public function test_get_period_clause_unknown_period() { + $this->assertSame( '', QueryHelper::get_period_clause( 'created_at', 'unknown' ) ); + $this->assertSame( '', QueryHelper::get_period_clause( 'created_at', '' ) ); + } + + /** + * Test get_period_clause rejects invalid column names. + * + * @since 4.1.1 + * + * @return void + */ + public function test_get_period_clause_invalid_column() { + $this->assertSame( '', QueryHelper::get_period_clause( "col; DROP TABLE", 'today' ) ); + $this->assertSame( '', QueryHelper::get_period_clause( '', 'today' ) ); + } + + // ─── prepare_table_name ───────────────────────────────────────────────── + + /** + * Test prepare_table_name adds prefix when missing. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_table_name_adds_prefix() { + global $wpdb; + $result = QueryHelper::prepare_table_name( 'my_table' ); + $this->assertSame( $wpdb->prefix . 'my_table', $result ); + } + + /** + * Test prepare_table_name does not double-prefix. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_table_name_no_double_prefix() { + global $wpdb; + $already_prefixed = $wpdb->prefix . 'my_table'; + $result = QueryHelper::prepare_table_name( $already_prefixed ); + $this->assertSame( $already_prefixed, $result ); + } + + // ─── get_valid_sort_order ─────────────────────────────────────────────── + + /** + * Test get_valid_sort_order. + * + * @since 4.1.1 + * + * @return void + */ + public function test_get_valid_sort_order() { + $this->assertSame( 'ASC', QueryHelper::get_valid_sort_order( 'ASC' ) ); + $this->assertSame( 'ASC', QueryHelper::get_valid_sort_order( 'asc' ) ); + $this->assertSame( 'DESC', QueryHelper::get_valid_sort_order( 'DESC' ) ); + $this->assertSame( 'DESC', QueryHelper::get_valid_sort_order( 'desc' ) ); + $this->assertSame( 'DESC', QueryHelper::get_valid_sort_order( 'INVALID' ) ); + $this->assertSame( 'DESC', QueryHelper::get_valid_sort_order( '' ) ); + } + + // ─── where clause injection safety ────────────────────────────────────── + + /** + * Test prepare_where_clause neutralizes injection in column keys. + * + * @since 4.1.1 + * + * @return void + */ + public function test_prepare_where_clause_injection_in_key() { + $where = array( "id = 1; DROP TABLE users --" => 'value' ); + $result = QueryHelper::prepare_where_clause( $where ); + // The injection key must be rejected, producing the safe fallback. + $this->assertStringContainsString( '1=0', $result ); + $this->assertStringNotContainsString( 'DROP', $result ); + } } From ff56865fe6b880d91c3016f3c6bedbf48f2f67fc Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Thu, 1 Oct 2026 15:19:48 +0600 Subject: [PATCH 035/218] guzzle removed and made necessary changes for it --- .../PaymentGateways/Paypal/composer.json | 3 +- .../PaymentGateways/Paypal/composer.lock | 690 +----------------- .../src/Contracts/Support/RequestContract.php | 36 + .../Contracts/Support/ResponseContract.php | 66 ++ .../Paypal/src/Core/Support/Request.php | 252 +++++++ .../Paypal/src/Core/Support/Response.php | 146 ++++ .../Paypal/src/Core/Support/System.php | 28 +- .../src/Exceptions/HttpRequestException.php | 51 ++ .../Paypal/src/Payments/Paypal/Api.php | 6 +- .../Paypal/src/Payments/Paypal/Helper.php | 9 +- .../Paypal/src/Payments/Paypal/Paypal.php | 34 +- 11 files changed, 598 insertions(+), 723 deletions(-) create mode 100644 ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php create mode 100644 ecommerce/PaymentGateways/Paypal/src/Contracts/Support/ResponseContract.php create mode 100644 ecommerce/PaymentGateways/Paypal/src/Core/Support/Request.php create mode 100644 ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php create mode 100644 ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php diff --git a/ecommerce/PaymentGateways/Paypal/composer.json b/ecommerce/PaymentGateways/Paypal/composer.json index 26952dc63d..c2b3c4a64e 100644 --- a/ecommerce/PaymentGateways/Paypal/composer.json +++ b/ecommerce/PaymentGateways/Paypal/composer.json @@ -10,7 +10,6 @@ } }, "require": { - "guzzlehttp/guzzle": "^7.9.2", "brick/money": "0.7" } -} \ No newline at end of file +} diff --git a/ecommerce/PaymentGateways/Paypal/composer.lock b/ecommerce/PaymentGateways/Paypal/composer.lock index 4e348525c2..494a254bb5 100644 --- a/ecommerce/PaymentGateways/Paypal/composer.lock +++ b/ecommerce/PaymentGateways/Paypal/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "782d4c9085c0db1d1581d526ad727646", + "content-hash": "f2bbb9730c5388ea55b20c93e9c6bab6", "packages": [ { "name": "brick/math", @@ -119,692 +119,6 @@ } ], "time": "2022-10-05T22:11:12+00:00" - }, - { - "name": "guzzlehttp/guzzle", - "version": "7.11.1", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.11", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.5", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.1" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2026-06-07T22:54:06+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.5.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2026-06-02T12:23:43+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.11.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "1.1.0", - "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2026-06-02T12:30:48+00:00" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v2.5.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", - "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "2.5-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:11:13+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T16:19:22+00:00" } ], "packages-dev": [], @@ -815,5 +129,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php new file mode 100644 index 0000000000..10e9960f21 --- /dev/null +++ b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php @@ -0,0 +1,36 @@ +method = strtoupper( $method ); + $this->url = $url; + $this->options = $options; + } + + /** + * Create a request from the { method, url, options } object used by the gateways. + * + * @param object $request_data The request data object. + * + * @return static + */ + public static function from_object( object $request_data ) { + return new static( $request_data->method, $request_data->url, (array) ( $request_data->options ?? array() ) ); + } + + /** + * Send a GET request. + * + * @param string $url The request URL. + * @param array $options The request options. + * + * @return ResponseContract + * @throws HttpRequestException + */ + public static function get( string $url, array $options = array() ): ResponseContract { + return ( new static( 'GET', $url, $options ) )->send(); + } + + /** + * Send a POST request. + * + * @param string $url The request URL. + * @param array $options The request options. + * + * @return ResponseContract + * @throws HttpRequestException + */ + public static function post( string $url, array $options = array() ): ResponseContract { + return ( new static( 'POST', $url, $options ) )->send(); + } + + /** + * Send a PUT request. + * + * @param string $url The request URL. + * @param array $options The request options. + * + * @return ResponseContract + * @throws HttpRequestException + */ + public static function put( string $url, array $options = array() ): ResponseContract { + return ( new static( 'PUT', $url, $options ) )->send(); + } + + /** + * Send a PATCH request. + * + * @param string $url The request URL. + * @param array $options The request options. + * + * @return ResponseContract + * @throws HttpRequestException + */ + public static function patch( string $url, array $options = array() ): ResponseContract { + return ( new static( 'PATCH', $url, $options ) )->send(); + } + + /** + * Send the request and decode the JSON response. + * + * Replacement for System::sendHttpRequest(). + * + * @param object $request_data The { method, url, options } request data object. + * @param bool $raw Return the response object instead of the decoded body. + * + * @return mixed|ResponseContract + * @throws HttpRequestException + */ + public static function send_http_request( object $request_data, bool $raw = false ) { + $response = static::from_object( $request_data )->send(); + + return $raw ? $response : $response->json(); + } + + /** + * Send the request. + * + * @return ResponseContract + * @throws HttpRequestException On a transport error, or on a 4xx/5xx status unless http_errors is false. + */ + public function send(): ResponseContract { + $raw = wp_remote_request( $this->build_url(), $this->build_args() ); + + if ( is_wp_error( $raw ) ) { + throw new HttpRequestException( esc_html( $raw->get_error_message() ) ); + } + + $response = new Response( $raw ); + $status = $response->get_status_code(); + + if ( $status >= 400 && ( $this->options['http_errors'] ?? true ) ) { + throw new HttpRequestException( + esc_html( sprintf( 'HTTP %d %s returned from %s', $status, $response->get_reason_phrase(), $this->url ) ), + $status, + $response + ); + } + + return $response; + } + + /** + * Get the HTTP method. + * + * @return string + */ + public function get_method(): string { + return $this->method; + } + + /** + * Get the request URL, without the query option applied. + * + * @return string + */ + public function get_url(): string { + return $this->url; + } + + /** + * Get the request options. + * + * @return array + */ + public function get_options(): array { + return $this->options; + } + + /** + * Build the final URL with the query option applied. + * + * @return string + */ + protected function build_url(): string { + if ( empty( $this->options['query'] ) ) { + return $this->url; + } + + return add_query_arg( urlencode_deep( $this->options['query'] ), $this->url ); + } + + /** + * Convert the Guzzle-style options to wp_remote_request() arguments. + * + * @return array + */ + protected function build_args(): array { + $options = $this->options; + $headers = $options['headers'] ?? array(); + $body = $options['body'] ?? null; + + if ( array_key_exists( 'json', $options ) ) { + $body = wp_json_encode( $options['json'] ); + + if ( ! $this->has_header( $headers, 'Content-Type' ) ) { + $headers['Content-Type'] = 'application/json'; + } + } elseif ( isset( $options['form_params'] ) ) { + // WordPress encodes array bodies as application/x-www-form-urlencoded. + $body = $options['form_params']; + } + + if ( ! empty( $options['auth'] ) ) { + $headers['Authorization'] = is_array( $options['auth'] ) + ? 'Basic ' . base64_encode( $options['auth'][0] . ':' . ( $options['auth'][1] ?? '' ) ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + : $options['auth']; + } + + return array( + 'method' => $this->method, + 'headers' => $headers, + 'body' => $body, + 'timeout' => $options['timeout'] ?? 30, + ); + } + + /** + * Determine if a header is set, ignoring case. + * + * @param array $headers The headers. + * @param string $name The header name. + * + * @return bool + */ + protected function has_header( array $headers, string $name ): bool { + return in_array( strtolower( $name ), array_map( 'strtolower', array_keys( $headers ) ), true ); + } +} diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php new file mode 100644 index 0000000000..e89c25f21b --- /dev/null +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php @@ -0,0 +1,146 @@ +raw = $raw; + } + + /** + * Get the HTTP status code. + * + * @return int + */ + public function get_status_code(): int { + return (int) wp_remote_retrieve_response_code( $this->raw ); + } + + /** + * Get the HTTP reason phrase, e.g. "Not Found". + * + * @return string + */ + public function get_reason_phrase(): string { + return (string) wp_remote_retrieve_response_message( $this->raw ); + } + + /** + * Determine if the status code is in the 2xx range. + * + * @return bool + */ + public function is_successful(): bool { + $status = $this->get_status_code(); + + return $status >= 200 && $status < 300; + } + + /** + * Get all response headers as an associative array with lowercase keys. + * + * @return array + */ + public function get_headers(): array { + $headers = wp_remote_retrieve_headers( $this->raw ); + + if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) { + return $headers->getAll(); + } + + return is_array( $headers ) ? $headers : array(); + } + + /** + * Get a single response header (case-insensitive). + * + * @param string $name The header name. + * @param mixed $default The value to return when the header is missing. + * + * @return string|array|mixed + */ + public function get_header( string $name, $default = null ) { + $value = wp_remote_retrieve_header( $this->raw, $name ); + + return '' === $value ? $default : $value; + } + + /** + * Get the response body with any UTF-8 BOM stripped. + * + * @return string + */ + public function get_body(): string { + if ( is_null( $this->body ) ) { + $body = (string) wp_remote_retrieve_body( $this->raw ); + + if ( 0 === strncmp( $body, "\xEF\xBB\xBF", 3 ) ) { + $body = substr( $body, 3 ); + } + + $this->body = $body; + } + + return $this->body; + } + + /** + * Decode the JSON response body. + * + * @param bool $assoc Return associative arrays instead of objects. + * + * @return mixed Null if the body is empty or not valid JSON. + */ + public function json( bool $assoc = false ) { + $body = $this->get_body(); + + if ( '' === $body ) { + return null; + } + + return json_decode( $body, $assoc ); + } + + /** + * Get the raw response array returned by wp_remote_request(). + * + * @return array + */ + public function get_raw(): array { + return $this->raw; + } + + /** + * Get the response body as a string. + * + * @return string + */ + public function __toString() { + return $this->get_body(); + } +} diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index 9187ac0bcc..1f5302fabc 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -3,7 +3,6 @@ use stdClass; use Brick\Money\Money; -use GuzzleHttp\Client; use Brick\Math\RoundingMode; use Ollyo\PaymentHub\Exceptions\NotFoundException; use Ollyo\PaymentHub\Exceptions\InvalidDataException; @@ -234,16 +233,27 @@ public static function updateWebhookUrl( $config ): string { /** * Sends an HTTP request using the specified method and options. * - * @param object $requestData An object containing the request method, URL, and options (e.g., headers, body). + * @since 4.1.2 + * + * @param object $request_data An object containing the request method, URL, and options (e.g., headers, body). + * * @return object|null The decoded JSON response body if $return is true, otherwise null. - * @since 1.0.0 */ - public static function sendHttpRequest( $requestData ) { - $http = new Client(); - $method = $requestData->method; - $requestUrl = $requestData->url; - $response = $http->$method( $requestUrl, $requestData->options ); + public static function sendHttpRequest( $request_data ) { + $method = strtolower( $request_data->method ); + if ( ! is_callable( array( Request::class, $method ) ) ) { + + $error_message = sprintf( + /* translators: %s: Method name */ + __( 'Unsupported HTTP method: %1$s', 'tutor' ), + $method + ); + throw new \InvalidArgumentException( $error_message ); //phpcs:ignore + } + + $url = $request_data->url; + $response = Request::$method( $url, $request_data->options ); - return json_decode( $response->getBody() ); + return json_decode( $response->get_body() ); } } diff --git a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php b/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php new file mode 100644 index 0000000000..792608e84c --- /dev/null +++ b/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php @@ -0,0 +1,51 @@ +response = $response; + } + + /** + * Get the response, or null for transport errors. + * + * @return ResponseContract|null + */ + public function get_response(): ?ResponseContract { + return $this->response; + } + + /** + * Determine if a response was received. + * + * @return bool + */ + public function has_response(): bool { + return ! is_null( $this->response ); + } +} diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php index c8994b1d30..d7f5b28d7b 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php @@ -4,8 +4,8 @@ use ErrorException; use Ollyo\PaymentHub\Core\Support\System; -use GuzzleHttp\Exception\RequestException; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; +use Ollyo\PaymentHub\Exceptions\HttpRequestException; /** * Paypal Api Class @@ -211,7 +211,7 @@ public static function get_order_details( $url ): ?object { return System::sendHttpRequest( $request_data ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $error_message ) ); } @@ -273,7 +273,7 @@ public static function get_vault_details( $url ): ?object { return System::sendHttpRequest( $request_data ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( $error_message ); //phpcs:ignore } diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php index 4efbd11607..abacd56e0c 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php @@ -4,9 +4,8 @@ use Ollyo\PaymentHub\Core\Support\Path; use Ollyo\PaymentHub\Core\Support\System; -use GuzzleHttp\Exception\RequestException; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; - +use Ollyo\PaymentHub\Exceptions\HttpRequestException; /** * Paypal Helper Class @@ -174,16 +173,16 @@ public static function getPaymentSourceForRecurring( &$returnData, $previous_pay * error message. It processes different parts of the error response, including issues, * details, and general error messages, and combines them into a single message string. * - * @param RequestException $errorResponse The error response from the HTTP request. + * @param HttpRequestException $errorResponse The error response from the HTTP request. * @return string|null The formatted error message. * @since 3.0.0 */ public static function handleErrorResponse( $errorResponse ): ?string { $message = ''; - if ( ! is_null( $errorResponse->getResponse() ) ) { + if ( ! is_null( $errorResponse->get_response() ) ) { - $errorBody = json_decode( $errorResponse->getResponse()->getBody() ); + $errorBody = json_decode( $errorResponse->get_response()->get_body() ); if ( ! empty( $errorBody->issues ) ) { $message .= self::processIssues( $errorBody->issues ); diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index 83e2f19ab2..784f9a74e1 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -6,11 +6,11 @@ use ErrorException; use Ollyo\PaymentHub\Core\Support\Arr; use Ollyo\PaymentHub\Core\Support\System; -use GuzzleHttp\Exception\RequestException; use Ollyo\PaymentHub\Core\Payment\BasePayment; use Ollyo\PaymentHub\Exceptions\NotFoundException; use Ollyo\PaymentHub\Exceptions\InvalidDataException; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; +use Ollyo\PaymentHub\Exceptions\HttpRequestException; class Paypal extends BasePayment { @@ -170,10 +170,11 @@ public function createPayment() { header( "Location: {$checkoutUrl}" ); exit(); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { - $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); - throw new ErrorException( esc_html( $errorMessage ) ); + $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); + error_log( 'Paypal Error: ' . $error_message ); //phpcs:ignore. + throw new ErrorException( esc_html__( 'Something Went Wrong', 'tutor' ) ); } } @@ -186,7 +187,7 @@ public function createPayment() { * * @param object $payload The payload object containing the webhook data. * @return object Returns the processed order data or an error response. - * @throws RequestException If the request fails. + * @throws HttpRequestException If the request fails. * @since 3.0.0 */ public function verifyAndCreateOrderData( object $payload ): object { @@ -219,7 +220,7 @@ public function verifyAndCreateOrderData( object $payload ): object { default: return new \stdClass(); } - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { // Handle the error response. $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); @@ -270,10 +271,11 @@ private function setReturnData( $payloadStream ): object { public function createRecurringPayment() { try { Api::createOrder( $this->getData(), $this->orderID ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { - $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); - throw new ErrorException( esc_html( $errorMessage ) ); + $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); + error_log( 'Paypal Error: ', $error_message ); + throw new ErrorException( esc_html__( 'Something Went Wrong', 'tutor' ) ); } } @@ -319,7 +321,7 @@ public function createRefund() { try { Api::refund( $this->refundLink, $this->orderID, $this->getData() ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $errorMessage ) ); } @@ -328,7 +330,7 @@ public function createRefund() { /** * Retrieves the refund status based on the provided links and type. * - * @param array $links An array of links provided by the PayPal API. + * @param array $links An array of links provided by the PayPal API. * @param string|null $type The type of link to use. * * @since 1.0.0 @@ -366,7 +368,7 @@ public function createWebhook(): ?object { 'options' => array( 'headers' => $this->headers ), ); - $responseData = $this->sendHttpRequest( $requestData ); + $responseData = System::sendHttpRequest( $requestData ); if ( isset( $responseData->webhooks ) && is_array( $responseData->webhooks ) ) { @@ -378,7 +380,7 @@ public function createWebhook(): ?object { throw new ErrorException( 'Webhook Information Not Found' ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $errorMessage ) ); } @@ -412,7 +414,7 @@ private function createNewWebhook() { ), ); - $responseData = $this->sendHttpRequest( $requestData ); + $responseData = System::sendHttpRequest( $requestData ); if ( $responseData->url === $this->config->get( 'webhook_url' ) && $responseData->id ) { return (object) array( @@ -423,7 +425,7 @@ private function createNewWebhook() { throw new InvalidDataException( 'Invalid Webhook Information' ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $errorMessage ) ); } @@ -459,7 +461,7 @@ private function processRefund( $paymentData ): object { return $returnData; - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $errorMessage ) ); } From be637894ac037ab8d3c2d3ea5b601fbcc85951c3 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 15:38:05 +0600 Subject: [PATCH 036/218] feat: added chainable fluent QueryBuilder helper and unit test --- helpers/QueryBuilder.php | 1839 ++++++++++++++++++++++++++++ tests/phpunit/QueryBuilderTest.php | 428 +++++++ 2 files changed, 2267 insertions(+) create mode 100644 helpers/QueryBuilder.php create mode 100644 tests/phpunit/QueryBuilderTest.php diff --git a/helpers/QueryBuilder.php b/helpers/QueryBuilder.php new file mode 100644 index 0000000000..1ee2a6f0f5 --- /dev/null +++ b/helpers/QueryBuilder.php @@ -0,0 +1,1839 @@ +prepare(). + * + * @package Tutor\Helpers + * @since 4.2.0 + */ + +namespace Tutor\Helpers; + +/** + * Class QueryBuilder + * + * Fluent, chainable query builder for WordPress database operations. + * All user-supplied values are escaped via $wpdb->prepare(), and + * identifiers (columns, tables) are validated against a safe pattern. + * + * Usage: + * QueryBuilder::table('tutor_orders') + * ->where('status', 'completed') + * ->order_by('id', 'DESC') + * ->limit(10) + * ->get(); + * + * @since 4.2.0 + */ +class QueryBuilder { + + /** + * Table name (with prefix). + * + * @since 4.2.0 + * + * @var string + */ + private $table = ''; + + /** + * Table alias. + * + * @since 4.2.0 + * + * @var string + */ + private $alias = ''; + + /** + * Columns to select. + * + * @since 4.2.0 + * + * @var array + */ + private $columns = array(); + + /** + * Raw select expressions. + * + * @since 4.2.0 + * + * @var array + */ + private $raw_columns = array(); + + /** + * Whether to apply DISTINCT. + * + * @since 4.2.0 + * + * @var bool + */ + private $is_distinct = false; + + /** + * JOIN clauses. + * + * @since 4.2.0 + * + * @var array + */ + private $joins = array(); + + /** + * WHERE clause conditions. + * + * Each entry: [ 'sql' => string, 'boolean' => 'AND'|'OR' ]. + * + * @since 4.2.0 + * + * @var array + */ + private $wheres = array(); + + /** + * GROUP BY columns. + * + * @since 4.2.0 + * + * @var array + */ + private $group_by_columns = array(); + + /** + * HAVING clause conditions. + * + * @since 4.2.0 + * + * @var array + */ + private $havings = array(); + + /** + * ORDER BY clauses. + * + * @since 4.2.0 + * + * @var array + */ + private $order_by_clauses = array(); + + /** + * LIMIT value. + * + * @since 4.2.0 + * + * @var int|null + */ + private $limit_value = null; + + /** + * OFFSET value. + * + * @since 4.2.0 + * + * @var int + */ + private $offset_value = 0; + + /** + * Supported comparison operators. + * + * @since 4.2.0 + * + * @var string[] + */ + private static $valid_operators = array( + '=', + '!=', + '<>', + '<', + '>', + '<=', + '>=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'IS', + 'IS NOT', + 'BETWEEN', + 'NOT BETWEEN', + ); + + // ───────────────────────────────────────────── + // Factory + // ───────────────────────────────────────────── + + /** + * Create a new QueryBuilder instance for the given table. + * + * The table name will be automatically prefixed with the WordPress + * table prefix if not already present. + * + * @since 4.2.0 + * + * @param string $table Table name (without prefix is fine). + * + * @return self + */ + public static function table( string $table ): self { + $instance = new self(); + $instance->table = self::prepare_table_name( $table ); + + return $instance; + } + + // ───────────────────────────────────────────── + // SELECT + // ───────────────────────────────────────────── + + /** + * Set the columns to select. + * + * Accepts one or more column names. Multiple calls are additive. + * + * @since 4.2.0 + * + * @param string ...$columns Column names to select. + * + * @return self + */ + public function select( string ...$columns ): self { + foreach ( $columns as $column ) { + if ( self::is_valid_column( $column ) ) { + $this->columns[] = $column; + } + } + + return $this; + } + + /** + * Add a raw SELECT expression. + * + * Use this for aggregate functions, computed columns, or any SQL + * expression that cannot be represented as a simple column name. + * + * @since 4.2.0 + * + * @param string $expression Raw SQL expression, e.g., 'COUNT(*) AS total'. + * @param array $bindings Optional values to bind to placeholders. + * + * @return self + */ + public function select_raw( string $expression, array $bindings = array() ): self { + if ( ! empty( $bindings ) ) { + global $wpdb; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- User-provided expression with bindings. + $expression = $wpdb->prepare( $expression, $bindings ); + } + + $this->raw_columns[] = $expression; + + return $this; + } + + /** + * Apply DISTINCT to the query. + * + * @since 4.2.0 + * + * @return self + */ + public function distinct(): self { + $this->is_distinct = true; + + return $this; + } + + /** + * Set a table alias. + * + * @since 4.2.0 + * + * @param string $alias Alias for the primary table. + * + * @return self + */ + public function as( string $alias ): self { + if ( self::is_valid_identifier( $alias ) ) { + $this->alias = $alias; + } + + return $this; + } + + // ───────────────────────────────────────────── + // JOINs + // ───────────────────────────────────────────── + + /** + * Add an INNER JOIN clause. + * + * @since 4.2.0 + * + * @param string $table Table to join (may include alias, e.g., 'users AS u'). + * @param string $first First column in the ON condition. + * @param string $operator Comparison operator. + * @param string $second Second column in the ON condition. + * + * @return self + */ + public function join( string $table, string $first, string $operator, string $second ): self { + return $this->add_join( 'INNER', $table, $first, $operator, $second ); + } + + /** + * Add a LEFT JOIN clause. + * + * @since 4.2.0 + * + * @param string $table Table to join. + * @param string $first First column. + * @param string $operator Comparison operator. + * @param string $second Second column. + * + * @return self + */ + public function left_join( string $table, string $first, string $operator, string $second ): self { + return $this->add_join( 'LEFT', $table, $first, $operator, $second ); + } + + /** + * Add a RIGHT JOIN clause. + * + * @since 4.2.0 + * + * @param string $table Table to join. + * @param string $first First column. + * @param string $operator Comparison operator. + * @param string $second Second column. + * + * @return self + */ + public function right_join( string $table, string $first, string $operator, string $second ): self { + return $this->add_join( 'RIGHT', $table, $first, $operator, $second ); + } + + /** + * Internal method to add a JOIN clause. + * + * Validates join type, table alias, columns, and operator before + * adding to the query. + * + * @since 4.2.0 + * + * @param string $type Join type (INNER, LEFT, RIGHT, CROSS). + * @param string $table Table name, may include alias e.g., 'users AS u'. + * @param string $first First column in the ON condition. + * @param string $operator Comparison operator. + * @param string $second Second column in the ON condition. + * + * @return self + */ + private function add_join( string $type, string $table, string $first, string $operator, string $second ): self { + $type = strtoupper( $type ); + + $allowed_types = array( 'INNER', 'LEFT', 'RIGHT', 'CROSS' ); + if ( ! in_array( $type, $allowed_types, true ) ) { + return $this; + } + + // Parse "table AS alias" or "table alias" format. + $parts = preg_split( '/\s+(?:AS\s+)?/i', $table, 2 ); + $table_name = self::prepare_table_name( trim( $parts[0] ) ); + $table_alias = isset( $parts[1] ) ? trim( $parts[1] ) : ''; + + $table_expr = $table_name; + if ( ! empty( $table_alias ) && self::is_valid_identifier( $table_alias ) ) { + $table_expr .= " AS {$table_alias}"; + } + + if ( self::is_valid_column( $first ) + && self::is_valid_operator( $operator ) + && self::is_valid_column( $second ) + ) { + $operator = strtoupper( $operator ); + $this->joins[] = "{$type} JOIN {$table_expr} ON {$first} {$operator} {$second}"; + } + + return $this; + } + + // ───────────────────────────────────────────── + // WHERE + // ───────────────────────────────────────────── + + /** + * Add a WHERE condition. + * + * Two-argument form uses '=' operator: where('col', 'value'). + * Three-argument form uses custom operator: where('col', '>=', 5). + * Passing null as value auto-converts to IS NULL / IS NOT NULL. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $operator_or_value Operator (if 3 args) or value (if 2 args). + * @param mixed $value Value when operator is specified. + * + * @return self + */ + public function where( string $column, $operator_or_value, $value = null ): self { + return $this->add_where( $column, $operator_or_value, $value, 'AND', func_num_args() < 3 ); + } + + /** + * Add an OR WHERE condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $operator_or_value Operator or value. + * @param mixed $value Value when operator is specified. + * + * @return self + */ + public function or_where( string $column, $operator_or_value, $value = null ): self { + return $this->add_where( $column, $operator_or_value, $value, 'OR', func_num_args() < 3 ); + } + + /** + * Add a WHERE IN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param array $values Array of values. + * + * @return self + */ + public function where_in( string $column, array $values ): self { + return $this->add_where_in( $column, $values, 'IN', 'AND' ); + } + + /** + * Add a WHERE NOT IN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param array $values Array of values. + * + * @return self + */ + public function where_not_in( string $column, array $values ): self { + return $this->add_where_in( $column, $values, 'NOT IN', 'AND' ); + } + + /** + * Add an OR WHERE IN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param array $values Array of values. + * + * @return self + */ + public function or_where_in( string $column, array $values ): self { + return $this->add_where_in( $column, $values, 'IN', 'OR' ); + } + + /** + * Add an OR WHERE NOT IN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param array $values Array of values. + * + * @return self + */ + public function or_where_not_in( string $column, array $values ): self { + return $this->add_where_in( $column, $values, 'NOT IN', 'OR' ); + } + + /** + * Add a WHERE IS NULL condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * + * @return self + */ + public function where_null( string $column ): self { + if ( self::is_valid_column( $column ) ) { + $this->wheres[] = array( + 'sql' => "{$column} IS NULL", + 'boolean' => 'AND', + ); + } + + return $this; + } + + /** + * Add a WHERE IS NOT NULL condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * + * @return self + */ + public function where_not_null( string $column ): self { + if ( self::is_valid_column( $column ) ) { + $this->wheres[] = array( + 'sql' => "{$column} IS NOT NULL", + 'boolean' => 'AND', + ); + } + + return $this; + } + + /** + * Add an OR WHERE IS NULL condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * + * @return self + */ + public function or_where_null( string $column ): self { + if ( self::is_valid_column( $column ) ) { + $this->wheres[] = array( + 'sql' => "{$column} IS NULL", + 'boolean' => 'OR', + ); + } + + return $this; + } + + /** + * Add an OR WHERE IS NOT NULL condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * + * @return self + */ + public function or_where_not_null( string $column ): self { + if ( self::is_valid_column( $column ) ) { + $this->wheres[] = array( + 'sql' => "{$column} IS NOT NULL", + 'boolean' => 'OR', + ); + } + + return $this; + } + + /** + * Add a WHERE BETWEEN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $min Minimum value. + * @param mixed $max Maximum value. + * + * @return self + */ + public function where_between( string $column, $min, $max ): self { + return $this->add_where_between( $column, $min, $max, 'BETWEEN', 'AND' ); + } + + /** + * Add a WHERE NOT BETWEEN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $min Minimum value. + * @param mixed $max Maximum value. + * + * @return self + */ + public function where_not_between( string $column, $min, $max ): self { + return $this->add_where_between( $column, $min, $max, 'NOT BETWEEN', 'AND' ); + } + + /** + * Add an OR WHERE BETWEEN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $min Minimum value. + * @param mixed $max Maximum value. + * + * @return self + */ + public function or_where_between( string $column, $min, $max ): self { + return $this->add_where_between( $column, $min, $max, 'BETWEEN', 'OR' ); + } + + /** + * Add an OR WHERE NOT BETWEEN condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $min Minimum value. + * @param mixed $max Maximum value. + * + * @return self + */ + public function or_where_not_between( string $column, $min, $max ): self { + return $this->add_where_between( $column, $min, $max, 'NOT BETWEEN', 'OR' ); + } + + /** + * Add a WHERE LIKE condition. + * + * The value is automatically wrapped with '%' wildcards and escaped + * using $wpdb->esc_like() to prevent LIKE injection. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param string $value Search term (auto-wrapped with % wildcards). + * + * @return self + */ + public function where_like( string $column, string $value ): self { + return $this->add_where_like( $column, $value, 'LIKE', 'AND' ); + } + + /** + * Add a WHERE NOT LIKE condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param string $value Search term. + * + * @return self + */ + public function where_not_like( string $column, string $value ): self { + return $this->add_where_like( $column, $value, 'NOT LIKE', 'AND' ); + } + + /** + * Add an OR WHERE LIKE condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param string $value Search term. + * + * @return self + */ + public function or_where_like( string $column, string $value ): self { + return $this->add_where_like( $column, $value, 'LIKE', 'OR' ); + } + + /** + * Add an OR WHERE NOT LIKE condition. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param string $value Search term. + * + * @return self + */ + public function or_where_not_like( string $column, string $value ): self { + return $this->add_where_like( $column, $value, 'NOT LIKE', 'OR' ); + } + + /** + * Add a raw WHERE expression. + * + * Use $wpdb->prepare() format specifiers (%s, %d, %f) in the expression + * and pass corresponding values in the bindings array. + * + * @since 4.2.0 + * + * @param string $expression Raw SQL expression, e.g., 'DATE(col) = %s'. + * @param array $bindings Optional values to bind. + * + * @return self + */ + public function where_raw( string $expression, array $bindings = array() ): self { + return $this->add_where_raw( $expression, $bindings, 'AND' ); + } + + /** + * Add a raw OR WHERE expression. + * + * @since 4.2.0 + * + * @param string $expression Raw SQL expression. + * @param array $bindings Optional values to bind. + * + * @return self + */ + public function or_where_raw( string $expression, array $bindings = array() ): self { + return $this->add_where_raw( $expression, $bindings, 'OR' ); + } + + // ───────────────────────────────────────────── + // GROUP BY + // ───────────────────────────────────────────── + + /** + * Add GROUP BY columns. + * + * @since 4.2.0 + * + * @param string ...$columns Column names to group by. + * + * @return self + */ + public function group_by( string ...$columns ): self { + foreach ( $columns as $column ) { + if ( self::is_valid_column( $column ) ) { + $this->group_by_columns[] = $column; + } + } + + return $this; + } + + // ───────────────────────────────────────────── + // HAVING + // ───────────────────────────────────────────── + + /** + * Add a raw HAVING expression. + * + * @since 4.2.0 + * + * @param string $expression Raw SQL expression, e.g., 'COUNT(*) > %d'. + * @param array $bindings Optional values to bind. + * + * @return self + */ + public function having_raw( string $expression, array $bindings = array() ): self { + if ( ! empty( $bindings ) ) { + global $wpdb; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Raw expression with user-controlled bindings. + $expression = $wpdb->prepare( $expression, $bindings ); + } + + $this->havings[] = $expression; + + return $this; + } + + // ───────────────────────────────────────────── + // ORDER BY + // ───────────────────────────────────────────── + + /** + * Add an ORDER BY clause. + * + * Multiple calls add multiple sort columns. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param string $direction ASC or DESC, default ASC. + * + * @return self + */ + public function order_by( string $column, string $direction = 'ASC' ): self { + if ( ! self::is_valid_column( $column ) ) { + return $this; + } + + $direction = 'DESC' === strtoupper( $direction ) ? 'DESC' : 'ASC'; + $this->order_by_clauses[] = "{$column} {$direction}"; + + return $this; + } + + /** + * Add a raw ORDER BY expression. + * + * @since 4.2.0 + * + * @param string $expression Raw SQL expression. + * @param array $bindings Optional values to bind. + * + * @return self + */ + public function order_by_raw( string $expression, array $bindings = array() ): self { + if ( ! empty( $bindings ) ) { + global $wpdb; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $expression = $wpdb->prepare( $expression, $bindings ); + } + + $this->order_by_clauses[] = $expression; + + return $this; + } + + /** + * Order results by the given column descending (default 'id'). + * + * @since 4.2.0 + * + * @param string $column Column name, default 'id'. + * + * @return self + */ + public function latest( string $column = 'id' ): self { + return $this->order_by( $column, 'DESC' ); + } + + /** + * Order results by the given column ascending (default 'id'). + * + * @since 4.2.0 + * + * @param string $column Column name, default 'id'. + * + * @return self + */ + public function oldest( string $column = 'id' ): self { + return $this->order_by( $column, 'ASC' ); + } + + // ───────────────────────────────────────────── + // LIMIT / OFFSET + // ───────────────────────────────────────────── + + /** + * Set the query LIMIT. + * + * @since 4.2.0 + * + * @param int $limit Number of rows to return. + * + * @return self + */ + public function limit( int $limit ): self { + $this->limit_value = max( 0, $limit ); + + return $this; + } + + /** + * Set the query OFFSET. + * + * @since 4.2.0 + * + * @param int $offset Number of rows to skip. + * + * @return self + */ + public function offset( int $offset ): self { + $this->offset_value = max( 0, $offset ); + + return $this; + } + + // ───────────────────────────────────────────── + // Terminal Methods (execute & return results) + // ───────────────────────────────────────────── + + /** + * Execute the query and return all matching rows. + * + * @since 4.2.0 + * + * @param string $output Output type: OBJECT, ARRAY_A, ARRAY_N. + * + * @return array Array of result rows. + */ + public function get( string $output = 'OBJECT' ): array { + global $wpdb; + + $sql = $this->build_select_query(); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL built with prepare_value/prepare calls. + $results = $wpdb->get_results( $sql, $output ); + + return is_array( $results ) ? $results : array(); + } + + /** + * Execute the query and return the first matching row. + * + * @since 4.2.0 + * + * @param string $output Output type: OBJECT, ARRAY_A, ARRAY_N. + * + * @return object|array|null Single row or null if not found. + */ + public function first( string $output = 'OBJECT' ) { + global $wpdb; + + // Temporarily set limit to 1. + $saved_limit = $this->limit_value; + $this->limit_value = 1; + + $sql = $this->build_select_query(); + + $this->limit_value = $saved_limit; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_row( $sql, $output ); + } + + /** + * Get the count of rows matching the query. + * + * @since 4.2.0 + * + * @param string $column Column to count, default '*'. + * + * @return int Total matching row count. + */ + public function count( string $column = '*' ): int { + global $wpdb; + + if ( '*' !== $column && ! self::is_valid_column( $column ) ) { + $column = '*'; + } + + // Save state that count() modifies. + $saved_columns = $this->columns; + $saved_raw = $this->raw_columns; + $saved_order = $this->order_by_clauses; + $saved_limit = $this->limit_value; + $saved_offset = $this->offset_value; + $saved_distinct = $this->is_distinct; + + // Override for count. + $this->columns = array(); + $this->raw_columns = array( "COUNT({$column}) AS aggregate" ); + $this->order_by_clauses = array(); + $this->limit_value = null; + $this->offset_value = 0; + $this->is_distinct = false; + + $sql = $this->build_select_query(); + + // Restore state. + $this->columns = $saved_columns; + $this->raw_columns = $saved_raw; + $this->order_by_clauses = $saved_order; + $this->limit_value = $saved_limit; + $this->offset_value = $saved_offset; + $this->is_distinct = $saved_distinct; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return (int) $wpdb->get_var( $sql ); + } + + /** + * Execute the query with pagination. + * + * @since 4.2.0 + * + * @param int $per_page Results per page. + * @param int $current_page Current page number (1-indexed). + * @param string $output Output type: OBJECT, ARRAY_A. + * + * @return array { + * @type int $total_count Total matching records. + * @type int $per_page Results per page. + * @type int $current_page Current page number. + * @type int $total_pages Total number of pages. + * @type array $results Array of result rows. + * } + */ + public function paginate( int $per_page, int $current_page = 1, string $output = 'OBJECT' ): array { + global $wpdb; + + $current_page = max( 1, $current_page ); + $per_page = max( 1, $per_page ); + + $this->limit_value = $per_page; + $this->offset_value = ( $current_page - 1 ) * $per_page; + + $sql = $this->build_select_query( true ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( $sql, $output ); + + $has_records = is_array( $results ) && count( $results ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $total_count = $has_records ? (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ) : 0; + $total_pages = (int) ceil( $total_count / $per_page ); + + return array( + 'total_count' => $total_count, + 'per_page' => $per_page, + 'current_page' => $current_page, + 'total_pages' => $total_pages, + 'results' => is_array( $results ) ? $results : array(), + ); + } + + /** + * Get a single column value from the first matching row. + * + * @since 4.2.0 + * + * @param string $column Column name to retrieve. + * + * @return mixed|null The column value or null if not found. + */ + public function value( string $column ) { + global $wpdb; + + if ( ! self::is_valid_column( $column ) ) { + return null; + } + + // Save state. + $saved_columns = $this->columns; + $saved_raw = $this->raw_columns; + $saved_limit = $this->limit_value; + + // Override for single value. + $this->columns = array( $column ); + $this->raw_columns = array(); + $this->limit_value = 1; + + $sql = $this->build_select_query(); + + // Restore state. + $this->columns = $saved_columns; + $this->raw_columns = $saved_raw; + $this->limit_value = $saved_limit; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_var( $sql ); + } + + /** + * Retrieve a list of values of a single column. + * + * If a key column is provided, the resulting array will use that column's + * values as array keys. + * + * @since 4.2.0 + * + * @param string $column Column value to retrieve. + * @param string|null $key Optional column to use as array key. + * + * @return array Array of column values. + */ + public function pluck( string $column, ?string $key = null ): array { + if ( ! self::is_valid_column( $column ) ) { + return array(); + } + + $cols = array( $column ); + if ( null !== $key && self::is_valid_column( $key ) ) { + $cols[] = $key; + } + + $saved_columns = $this->columns; + $saved_raw = $this->raw_columns; + + $this->columns = $cols; + $this->raw_columns = array(); + + $results = $this->get( 'ARRAY_A' ); + + $this->columns = $saved_columns; + $this->raw_columns = $saved_raw; + + if ( empty( $results ) ) { + return array(); + } + + $col_clean = false !== strpos( $column, '.' ) ? substr( $column, strrpos( $column, '.' ) + 1 ) : $column; + $key_clean = ( null !== $key && false !== strpos( $key, '.' ) ) ? substr( $key, strrpos( $key, '.' ) + 1 ) : $key; + + if ( null !== $key_clean ) { + return array_column( $results, $col_clean, $key_clean ); + } + + return array_column( $results, $col_clean ); + } + + /** + * Determine if any matching records exist for the current query. + * + * @since 4.2.0 + * + * @return bool True if at least one matching record exists. + */ + public function exists(): bool { + return null !== $this->first(); + } + + /** + * Find a single record by its primary key. + * + * @since 4.2.0 + * + * @param int|string $id Primary key value. + * @param string $primary_key Primary key column name, default 'id'. + * @param string $output Output type: OBJECT, ARRAY_A, ARRAY_N. + * + * @return object|array|null + */ + public function find( $id, string $primary_key = 'id', string $output = 'OBJECT' ) { + return $this->where( $primary_key, $id )->first( $output ); + } + + /** + * Apply the given callback if the condition is truthy. + * + * @since 4.2.0 + * + * @param mixed $condition Truthy/falsy condition. + * @param callable $callback Callback receiving ($this, $condition). + * @param callable|null $default_callback Optional callback if condition is falsy. + * + * @return self + */ + public function when( $condition, callable $callback, ?callable $default_callback = null ): self { + if ( $condition ) { + $callback( $this, $condition ); + } elseif ( null !== $default_callback ) { + $default_callback( $this, $condition ); + } + + return $this; + } + + /** + * Insert a single row and return the insert ID. + * + * Uses $wpdb->insert() which automatically handles escaping. + * + * @since 4.2.0 + * + * @param array $data Assoc array of column => value pairs. + * @param array $format Optional format specifiers (%s, %d, %f) for each value. + * + * @throws \Exception If a database error occurs. + * + * @return int Inserted row ID. + */ + public function insert( array $data, array $format = array() ): int { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( $this->table, $data, ! empty( $format ) ? $format : null ); + + if ( $wpdb->last_error ) { + throw new \Exception( esc_html( $wpdb->last_error ) ); + } + + return $result ? $wpdb->insert_id : 0; + } + + /** + * Insert multiple rows in a single query. + * + * All rows must have the same columns. Values are individually escaped + * using $wpdb->prepare(). NULL values are inserted as SQL NULL. + * + * @since 4.2.0 + * + * @param array $rows Array of assoc arrays, each representing a row. + * + * @throws \Exception If a database error occurs. + * + * @return bool True on success. + */ + public function insert_multiple( array $rows ): bool { + global $wpdb; + + if ( empty( $rows ) ) { + return false; + } + + $columns = array_keys( $rows[0] ); + $column_list = implode( ', ', array_map( 'sanitize_key', $columns ) ); + + $value_groups = array(); + foreach ( $rows as $row ) { + $row_parts = array(); + foreach ( $columns as $col ) { + $val = $row[ $col ] ?? null; + if ( is_null( $val ) ) { + $row_parts[] = 'NULL'; + } else { + $row_parts[] = self::prepare_value( $val ); + } + } + $value_groups[] = '(' . implode( ', ', $row_parts ) . ')'; + } + + $sql = "INSERT INTO {$this->table} ({$column_list}) VALUES " . implode( ', ', $value_groups ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Values prepared inline via prepare_value(). + $wpdb->query( $sql ); + + if ( $wpdb->last_error ) { + throw new \Exception( esc_html( $wpdb->last_error ) ); + } + + return true; + } + + /** + * Update rows matching the accumulated WHERE conditions. + * + * All SET values are individually escaped using $wpdb->prepare(). + * NULL values produce `SET column = NULL`. + * + * @since 4.2.0 + * + * @param array $data Assoc array of column => value pairs to update. + * + * @throws \Exception If a database error occurs. + * + * @return bool True on success. + */ + public function update( array $data ): bool { + global $wpdb; + + if ( empty( $data ) ) { + return false; + } + + $set_parts = array(); + foreach ( $data as $column => $value ) { + $safe_column = sanitize_key( $column ); + if ( is_null( $value ) ) { + $set_parts[] = "{$safe_column} = NULL"; + } else { + $prepared = self::prepare_value( $value ); + $set_parts[] = "{$safe_column} = {$prepared}"; + } + } + + $set_clause = implode( ', ', $set_parts ); + $where_clause = $this->build_where_sql(); + + $table_expr = ! empty( $this->alias ) ? "{$this->table} AS {$this->alias}" : $this->table; + $sql = "UPDATE {$table_expr} SET {$set_clause}"; + if ( ! empty( $where_clause ) ) { + $sql .= " WHERE {$where_clause}"; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Values prepared inline. + $wpdb->query( $sql ); + + if ( $wpdb->last_error ) { + throw new \Exception( esc_html( $wpdb->last_error ) ); + } + + return true; + } + + /** + * Delete rows matching the accumulated WHERE conditions. + * + * WARNING: Calling delete() without any where() conditions will + * delete ALL rows from the table. + * + * @since 4.2.0 + * + * @throws \Exception If a database error occurs. + * + * @return int Number of affected rows. + */ + public function delete(): int { + global $wpdb; + + $where_clause = $this->build_where_sql(); + + if ( ! empty( $this->alias ) ) { + $sql = "DELETE {$this->alias} FROM {$this->table} AS {$this->alias}"; + } else { + $sql = "DELETE FROM {$this->table}"; + } + + if ( ! empty( $where_clause ) ) { + $sql .= " WHERE {$where_clause}"; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WHERE values prepared inline. + $result = $wpdb->query( $sql ); + + if ( $wpdb->last_error ) { + throw new \Exception( esc_html( $wpdb->last_error ) ); + } + + return (int) $result; + } + + // ───────────────────────────────────────────── + // Debug + // ───────────────────────────────────────────── + + /** + * Get the generated SQL string without executing. + * + * Useful for debugging and logging. Returns the SELECT query as it + * would be sent to the database. + * + * @since 4.2.0 + * + * @return string The generated SQL string. + */ + public function to_sql(): string { + global $wpdb; + + $sql = $this->build_select_query(); + + if ( isset( $wpdb ) && method_exists( $wpdb, 'remove_placeholder_escape' ) ) { + return $wpdb->remove_placeholder_escape( $sql ); + } + + return $sql; + } + + // ───────────────────────────────────────────── + // Internal WHERE helpers + // ───────────────────────────────────────────── + + /** + * Internal method to add a basic WHERE condition. + * + * Handles two-argument (default '=') and three-argument (custom operator) + * forms. Null values are auto-converted to IS NULL / IS NOT NULL. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $operator_or_value Operator or value. + * @param mixed $value Value (null when in two-arg form). + * @param string $boolean AND or OR. + * @param bool $is_two_arg Whether this is a two-argument call. + * + * @return self + */ + private function add_where( string $column, $operator_or_value, $value, string $boolean, bool $is_two_arg ): self { + global $wpdb; + + if ( ! self::is_valid_column( $column ) ) { + return $this; + } + + // Determine operator and value. + if ( $is_two_arg ) { + $value = $operator_or_value; + $operator = '='; + } else { + $operator = strtoupper( (string) $operator_or_value ); + } + + // Handle null values → IS NULL / IS NOT NULL. + if ( is_null( $value ) ) { + $null_op = in_array( $operator, array( '!=', '<>' ), true ) ? 'IS NOT' : 'IS'; + $this->wheres[] = array( + 'sql' => "{$column} {$null_op} NULL", + 'boolean' => $boolean, + ); + + return $this; + } + + if ( ! self::is_valid_operator( $operator ) ) { + return $this; + } + + $prepared_value = self::prepare_value( $value ); + + $this->wheres[] = array( + 'sql' => "{$column} {$operator} {$prepared_value}", + 'boolean' => $boolean, + ); + + return $this; + } + + /** + * Internal method to add WHERE IN / NOT IN conditions. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param array $values Array of values. + * @param string $type IN or NOT IN. + * @param string $boolean AND or OR. + * + * @return self + */ + private function add_where_in( string $column, array $values, string $type, string $boolean ): self { + if ( ! self::is_valid_column( $column ) || empty( $values ) ) { + return $this; + } + + $escaped_values = self::prepare_in_clause( $values ); + + $this->wheres[] = array( + 'sql' => "{$column} {$type} ({$escaped_values})", + 'boolean' => $boolean, + ); + + return $this; + } + + /** + * Internal method for BETWEEN / NOT BETWEEN conditions. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param mixed $min Minimum value. + * @param mixed $max Maximum value. + * @param string $type BETWEEN or NOT BETWEEN. + * @param string $boolean AND or OR. + * + * @return self + */ + private function add_where_between( string $column, $min, $max, string $type, string $boolean ): self { + if ( ! self::is_valid_column( $column ) ) { + return $this; + } + + $min_prepared = self::prepare_value( $min ); + $max_prepared = self::prepare_value( $max ); + + $this->wheres[] = array( + 'sql' => "{$column} {$type} {$min_prepared} AND {$max_prepared}", + 'boolean' => $boolean, + ); + + return $this; + } + + /** + * Internal method for LIKE / NOT LIKE conditions. + * + * Escapes the value with $wpdb->esc_like() and wraps with '%' wildcards. + * + * @since 4.2.0 + * + * @param string $column Column name. + * @param string $value Search term. + * @param string $type LIKE or NOT LIKE. + * @param string $boolean AND or OR. + * + * @return self + */ + private function add_where_like( string $column, string $value, string $type, string $boolean ): self { + global $wpdb; + + if ( ! self::is_valid_column( $column ) ) { + return $this; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Column validated, type is hardcoded. + $sql = $wpdb->prepare( "{$column} {$type} %s", '%' . $wpdb->esc_like( $value ) . '%' ); + + $this->wheres[] = array( + 'sql' => $sql, + 'boolean' => $boolean, + ); + + return $this; + } + + /** + * Internal method for raw WHERE expressions. + * + * @since 4.2.0 + * + * @param string $expression Raw SQL expression. + * @param array $bindings Values to bind. + * @param string $boolean AND or OR. + * + * @return self + */ + private function add_where_raw( string $expression, array $bindings, string $boolean ): self { + if ( ! empty( $bindings ) ) { + global $wpdb; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Raw expression with user-controlled bindings. + $expression = $wpdb->prepare( $expression, $bindings ); + } + + $this->wheres[] = array( + 'sql' => $expression, + 'boolean' => $boolean, + ); + + return $this; + } + + // ───────────────────────────────────────────── + // SQL Building (private) + // ───────────────────────────────────────────── + + /** + * Build the complete SELECT query string. + * + * @since 4.2.0 + * + * @param bool $calc_found_rows Whether to include SQL_CALC_FOUND_ROWS. + * + * @return string The complete SQL query. + */ + private function build_select_query( bool $calc_found_rows = false ): string { + $select = $this->build_select_sql(); + $from = $this->build_from_sql(); + $join = $this->build_join_sql(); + $where = $this->build_where_sql(); + $group = $this->build_group_by_sql(); + $having = $this->build_having_sql(); + $order = $this->build_order_by_sql(); + $limit = $this->build_limit_sql(); + + $parts = array( 'SELECT' ); + + if ( $calc_found_rows ) { + $parts[] = 'SQL_CALC_FOUND_ROWS'; + } + + if ( $this->is_distinct ) { + $parts[] = 'DISTINCT'; + } + + $parts[] = $select; + $parts[] = "FROM {$from}"; + + if ( ! empty( $join ) ) { + $parts[] = $join; + } + + if ( ! empty( $where ) ) { + $parts[] = "WHERE {$where}"; + } + + if ( ! empty( $group ) ) { + $parts[] = "GROUP BY {$group}"; + } + + if ( ! empty( $having ) ) { + $parts[] = "HAVING {$having}"; + } + + if ( ! empty( $order ) ) { + $parts[] = "ORDER BY {$order}"; + } + + if ( ! empty( $limit ) ) { + $parts[] = $limit; + } + + return implode( ' ', $parts ); + } + + /** + * Build SELECT column list. + * + * @since 4.2.0 + * + * @return string + */ + private function build_select_sql(): string { + $all_columns = array_merge( $this->columns, $this->raw_columns ); + + return empty( $all_columns ) ? '*' : implode( ', ', $all_columns ); + } + + /** + * Build FROM clause with optional alias. + * + * @since 4.2.0 + * + * @return string + */ + private function build_from_sql(): string { + if ( ! empty( $this->alias ) ) { + return "{$this->table} AS {$this->alias}"; + } + + return $this->table; + } + + /** + * Build JOIN clauses. + * + * @since 4.2.0 + * + * @return string + */ + private function build_join_sql(): string { + return implode( ' ', $this->joins ); + } + + /** + * Build WHERE clause (without the WHERE keyword). + * + * Joins conditions with their respective boolean operators (AND/OR). + * The first condition's boolean is always omitted. + * + * @since 4.2.0 + * + * @return string + */ + private function build_where_sql(): string { + if ( empty( $this->wheres ) ) { + return ''; + } + + $sql = ''; + foreach ( $this->wheres as $index => $where ) { + if ( 0 === $index ) { + $sql .= $where['sql']; + } else { + $sql .= " {$where['boolean']} {$where['sql']}"; + } + } + + return $sql; + } + + /** + * Build GROUP BY clause (without the GROUP BY keywords). + * + * @since 4.2.0 + * + * @return string + */ + private function build_group_by_sql(): string { + return implode( ', ', $this->group_by_columns ); + } + + /** + * Build HAVING clause (without the HAVING keyword). + * + * @since 4.2.0 + * + * @return string + */ + private function build_having_sql(): string { + return implode( ' AND ', $this->havings ); + } + + /** + * Build ORDER BY clause (without the ORDER BY keywords). + * + * @since 4.2.0 + * + * @return string + */ + private function build_order_by_sql(): string { + return implode( ', ', $this->order_by_clauses ); + } + + /** + * Build LIMIT/OFFSET clause. + * + * @since 4.2.0 + * + * @return string + */ + private function build_limit_sql(): string { + if ( is_null( $this->limit_value ) || $this->limit_value < 0 ) { + return ''; + } + + return sprintf( 'LIMIT %d OFFSET %d', $this->limit_value, $this->offset_value ); + } + + // ───────────────────────────────────────────── + // Validation Helpers + // ───────────────────────────────────────────── + + /** + * Validate a SQL column identifier. + * + * Allows: column, table.column, t.*, *, _col, col123, etc. + * + * @since 4.2.0 + * + * @param string $column Column identifier. + * + * @return bool True if valid. + */ + private static function is_valid_column( string $column ): bool { + return (bool) preg_match( '/^[A-Za-z_*][A-Za-z0-9_.*]*$/', $column ); + } + + /** + * Validate a SQL identifier (table name, alias). + * + * @since 4.2.0 + * + * @param string $identifier Identifier to validate. + * + * @return bool True if valid. + */ + private static function is_valid_identifier( string $identifier ): bool { + return (bool) preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier ); + } + + /** + * Check whether a comparison operator is supported. + * + * @since 4.2.0 + * + * @param string $operator SQL operator. + * + * @return bool True if the operator is valid. + */ + private static function is_valid_operator( string $operator ): bool { + return in_array( strtoupper( $operator ), self::$valid_operators, true ); + } + + /** + * Get the wpdb format placeholder for a value based on its PHP type. + * + * @since 4.2.0 + * + * @param mixed $value Value to determine placeholder for. + * + * @return string %d for int, %f for float, %s for everything else. + */ + private static function get_placeholder( $value ): string { + if ( is_int( $value ) ) { + return '%d'; + } + + if ( is_float( $value ) ) { + return '%f'; + } + + return '%s'; + } + + // ───────────────────────────────────────────── + // Value Preparation & DB Utilities (Self-Contained) + // ───────────────────────────────────────────── + + /** + * Prepare a single value for safe SQL inclusion using $wpdb->prepare(). + * + * Numbers and booleans are formatted accordingly; strings are escaped and quoted. + * NULL is returned as the unquoted literal string 'NULL'. + * + * @since 4.2.0 + * + * @param mixed $value Value to prepare. + * + * @return string Escaped SQL value literal. + */ + public static function prepare_value( $value ): string { + global $wpdb; + + if ( is_null( $value ) ) { + return 'NULL'; + } + + if ( is_bool( $value ) ) { + return $wpdb->prepare( '%d', $value ? 1 : 0 ); + } + + if ( is_int( $value ) ) { + return $wpdb->prepare( '%d', $value ); + } + + if ( is_float( $value ) ) { + $str_val = (string) $value; + $dot_pos = strpos( $str_val, '.' ); + if ( false !== $dot_pos ) { + $decimals = strlen( substr( $str_val, $dot_pos + 1 ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $wpdb->prepare( '%.' . $decimals . 'f', $value ); + } + return $wpdb->prepare( '%f', $value ); + } + + return $wpdb->prepare( '%s', $value ); + } + + /** + * Make sanitized SQL IN clause values from an array. + * + * @since 4.2.0 + * + * @param array $values Array of scalar values. + * + * @return string Comma-separated list of prepared values. + */ + public static function prepare_in_clause( array $values ): string { + $escaped = array_map( + function ( $value ) { + return self::prepare_value( $value ); + }, + $values + ); + + return implode( ', ', $escaped ); + } + + /** + * Prepare table name with WordPress prefix if not already prefixed. + * + * @since 4.2.0 + * + * @param string $table Table name. + * + * @return string Prefixed table name. + */ + public static function prepare_table_name( string $table ): string { + global $wpdb; + + $prefix = isset( $wpdb->prefix ) ? $wpdb->prefix : ''; + if ( ! empty( $prefix ) && 0 !== strpos( $table, $prefix ) ) { + return $prefix . $table; + } + + return $table; + } + + /** + * Get the WordPress database table prefix. + * + * @since 4.2.0 + * + * @return string Table prefix. + */ + public static function get_table_prefix(): string { + global $wpdb; + + return isset( $wpdb->prefix ) ? $wpdb->prefix : ''; + } + + /** + * Get the last executed query from $wpdb. + * + * @since 4.2.0 + * + * @return string Last executed SQL query. + */ + public static function get_last_query(): string { + global $wpdb; + + return isset( $wpdb->last_query ) ? $wpdb->last_query : ''; + } +} diff --git a/tests/phpunit/QueryBuilderTest.php b/tests/phpunit/QueryBuilderTest.php new file mode 100644 index 0000000000..ced64a85e4 --- /dev/null +++ b/tests/phpunit/QueryBuilderTest.php @@ -0,0 +1,428 @@ +prefix; + + // Without prefix. + $builder = QueryBuilder::table( 'tutor_orders' ); + $this->assertSame( "SELECT * FROM {$prefix}tutor_orders", $builder->to_sql() ); + + // With prefix already included. + $builder2 = QueryBuilder::table( "{$prefix}tutor_orders" ); + $this->assertSame( "SELECT * FROM {$prefix}tutor_orders", $builder2->to_sql() ); + } + + /** + * Test table alias. + * + * @return void + */ + public function test_table_alias() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->as( 'o' ) + ->to_sql(); + + $this->assertSame( "SELECT * FROM {$prefix}tutor_orders AS o", $sql ); + } + + /** + * Test select columns. + * + * @return void + */ + public function test_select_columns() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->select( 'id', 'order_status', 'total_price' ) + ->to_sql(); + + $this->assertSame( "SELECT id, order_status, total_price FROM {$prefix}tutor_orders", $sql ); + } + + /** + * Test select raw and distinct. + * + * @return void + */ + public function test_select_raw_and_distinct() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->distinct() + ->select( 'order_status' ) + ->select_raw( 'COUNT(*) AS total' ) + ->to_sql(); + + $this->assertSame( "SELECT DISTINCT order_status, COUNT(*) AS total FROM {$prefix}tutor_orders", $sql ); + } + + /** + * Test JOIN clauses. + * + * @return void + */ + public function test_joins() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->as( 'o' ) + ->select( 'o.id', 'u.user_login' ) + ->join( "{$wpdb->users} AS u", 'o.user_id', '=', 'u.ID' ) + ->left_join( 'posts AS p', 'o.course_id', '=', 'p.ID' ) + ->to_sql(); + + $expected = "SELECT o.id, u.user_login FROM {$prefix}tutor_orders AS o INNER JOIN {$wpdb->users} AS u ON o.user_id = u.ID LEFT JOIN {$prefix}posts AS p ON o.course_id = p.ID"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test basic WHERE conditions and operators. + * + * @return void + */ + public function test_where_conditions() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->where( 'order_status', 'completed' ) + ->where( 'total_price', '>', 50 ) + ->where( 'user_id', '=', 10 ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}tutor_orders WHERE order_status = 'completed' AND total_price > 50 AND user_id = 10"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test OR WHERE conditions. + * + * @return void + */ + public function test_or_where_conditions() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->where( 'order_status', 'completed' ) + ->or_where( 'order_status', 'processing' ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}tutor_orders WHERE order_status = 'completed' OR order_status = 'processing'"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test WHERE IN and WHERE NOT IN. + * + * @return void + */ + public function test_where_in_and_not_in() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->where_in( 'order_status', array( 'completed', 'pending' ) ) + ->where_not_in( 'user_id', array( 1, 2, 3 ) ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}tutor_orders WHERE order_status IN ('completed', 'pending') AND user_id NOT IN (1, 2, 3)"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test WHERE NULL and WHERE NOT NULL. + * + * @return void + */ + public function test_where_null_and_not_null() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->where_null( 'deleted_at' ) + ->where_not_null( 'completed_at' ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}tutor_orders WHERE deleted_at IS NULL AND completed_at IS NOT NULL"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test WHERE BETWEEN and NOT BETWEEN. + * + * @return void + */ + public function test_where_between() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->where_between( 'created_at', '2024-01-01', '2024-12-31' ) + ->where_not_between( 'total_price', 10, 20 ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}tutor_orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31' AND total_price NOT BETWEEN 10 AND 20"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test WHERE LIKE. + * + * @return void + */ + public function test_where_like() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'posts' ) + ->where_like( 'post_title', 'WordPress' ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}posts WHERE post_title LIKE '%WordPress%'"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test WHERE RAW with bindings. + * + * @return void + */ + public function test_where_raw() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->where_raw( 'DATE(created_at) = %s', array( '2024-05-01' ) ) + ->or_where_raw( 'id = %d', array( 99 ) ) + ->to_sql(); + + $expected = "SELECT * FROM {$prefix}tutor_orders WHERE DATE(created_at) = '2024-05-01' OR id = 99"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test GROUP BY, HAVING, and ORDER BY. + * + * @return void + */ + public function test_group_having_order() { + global $wpdb; + + $prefix = $wpdb->prefix; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->select( 'order_status' ) + ->select_raw( 'COUNT(*) AS total' ) + ->group_by( 'order_status' ) + ->having_raw( 'COUNT(*) > %d', array( 5 ) ) + ->order_by( 'total', 'DESC' ) + ->limit( 10 ) + ->offset( 20 ) + ->to_sql(); + + $expected = "SELECT order_status, COUNT(*) AS total FROM {$prefix}tutor_orders GROUP BY order_status HAVING COUNT(*) > 5 ORDER BY total DESC LIMIT 10 OFFSET 20"; + $this->assertSame( $expected, $sql ); + } + + /** + * Test latest and oldest helpers. + * + * @return void + */ + public function test_latest_and_oldest() { + global $wpdb; + + $prefix = $wpdb->prefix; + $latest = QueryBuilder::table( 'posts' )->latest()->to_sql(); + $this->assertSame( "SELECT * FROM {$prefix}posts ORDER BY id DESC", $latest ); + + $oldest = QueryBuilder::table( 'posts' )->oldest( 'post_date' )->to_sql(); + $this->assertSame( "SELECT * FROM {$prefix}posts ORDER BY post_date ASC", $oldest ); + } + + /** + * Test when() conditional helper. + * + * @return void + */ + public function test_when_helper() { + global $wpdb; + + $prefix = $wpdb->prefix; + + $search = 'active'; + $sql = QueryBuilder::table( 'tutor_orders' ) + ->when( + $search, + function ( $query, $value ) { + $query->where( 'status', $value ); + } + ) + ->when( + false, + function ( $query ) { + $query->where( 'status', 'ignored' ); + } + ) + ->to_sql(); + + $this->assertSame( "SELECT * FROM {$prefix}tutor_orders WHERE status = 'active'", $sql ); + } + + /** + * Test self-contained value preparation helpers. + * + * @return void + */ + public function test_prepare_value() { + $this->assertSame( '42', QueryBuilder::prepare_value( 42 ) ); + $this->assertSame( "'hello'", QueryBuilder::prepare_value( 'hello' ) ); + $this->assertSame( '1', QueryBuilder::prepare_value( true ) ); + $this->assertSame( '0', QueryBuilder::prepare_value( false ) ); + $this->assertSame( 'NULL', QueryBuilder::prepare_value( null ) ); + $this->assertSame( '19.99', QueryBuilder::prepare_value( 19.99 ) ); + } + + /** + * Test prepare_in_clause helper. + * + * @return void + */ + public function test_prepare_in_clause() { + $in = QueryBuilder::prepare_in_clause( array( 'apple', 123, 'banana' ) ); + $this->assertSame( "'apple', 123, 'banana'", $in ); + } + + /** + * Test identifier validation prevents injection in column names. + * + * @return void + */ + public function test_identifier_sanitization() { + global $wpdb; + + $prefix = $wpdb->prefix; + + // Malicious column attempt should be ignored. + $sql = QueryBuilder::table( 'tutor_orders' ) + ->select( 'id', 'status; DROP TABLE tutor_orders; --' ) + ->where( '1=1; --', 'val' ) + ->to_sql(); + + $this->assertSame( "SELECT id FROM {$prefix}tutor_orders", $sql ); + } + + /** + * Test live database execution: insert, find, first, value, pluck, count, exists, update, and delete. + * + * @return void + */ + public function test_database_crud_operations() { + // 1. Insert single row. + $unique_key = 'qb_test_' . time() . '_' . wp_rand( 1000, 9999 ); + $insert_id = QueryBuilder::table( 'options' ) + ->insert( + array( + 'option_name' => $unique_key, + 'option_value' => 'initial_value', + 'autoload' => 'no', + ) + ); + + $this->assertGreaterThan( 0, $insert_id ); + + // 2. exists(). + $exists = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->exists(); + $this->assertTrue( $exists ); + + // 3. find() by primary key. + $row = QueryBuilder::table( 'options' )->find( $insert_id, 'option_id' ); + $this->assertNotNull( $row ); + $this->assertSame( $unique_key, $row->option_name ); + $this->assertSame( 'initial_value', $row->option_value ); + + // 4. first(). + $first_row = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->first( 'ARRAY_A' ); + $this->assertIsArray( $first_row ); + $this->assertSame( 'initial_value', $first_row['option_value'] ); + + // 5. value(). + $val = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->value( 'option_value' ); + $this->assertSame( 'initial_value', $val ); + + // 6. count(). + $count = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->count(); + $this->assertSame( 1, $count ); + + // 7. pluck(). + $plucked = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->pluck( 'option_value' ); + $this->assertSame( array( 'initial_value' ), $plucked ); + + // 8. update(). + $updated = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->update( + array( + 'option_value' => 'updated_value', + ) + ); + $this->assertTrue( $updated ); + + $new_val = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->value( 'option_value' ); + $this->assertSame( 'updated_value', $new_val ); + + // 9. delete(). + $deleted = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->delete(); + $this->assertSame( 1, $deleted ); + + $after_delete_exists = QueryBuilder::table( 'options' ) + ->where( 'option_name', $unique_key ) + ->exists(); + $this->assertFalse( $after_delete_exists ); + } +} From 9647b74b026699b68e5e1bf51a334c42a8fd9e48 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 16:07:05 +0600 Subject: [PATCH 037/218] chore: update repository files --- helpers/{QueryBuilder.php => DB.php} | 8 +- .../{QueryBuilderTest.php => DBTest.php} | 82 +++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) rename helpers/{QueryBuilder.php => DB.php} (99%) rename tests/phpunit/{QueryBuilderTest.php => DBTest.php} (80%) diff --git a/helpers/QueryBuilder.php b/helpers/DB.php similarity index 99% rename from helpers/QueryBuilder.php rename to helpers/DB.php index 1ee2a6f0f5..b1565e913b 100644 --- a/helpers/QueryBuilder.php +++ b/helpers/DB.php @@ -13,14 +13,14 @@ namespace Tutor\Helpers; /** - * Class QueryBuilder + * Class DB * * Fluent, chainable query builder for WordPress database operations. * All user-supplied values are escaped via $wpdb->prepare(), and * identifiers (columns, tables) are validated against a safe pattern. * * Usage: - * QueryBuilder::table('tutor_orders') + * DB::table('tutor_orders') * ->where('status', 'completed') * ->order_by('id', 'DESC') * ->limit(10) @@ -28,7 +28,7 @@ * * @since 4.2.0 */ -class QueryBuilder { +class DB { /** * Table name (with prefix). @@ -170,7 +170,7 @@ class QueryBuilder { // ───────────────────────────────────────────── /** - * Create a new QueryBuilder instance for the given table. + * Create a new DB query instance for the given table. * * The table name will be automatically prefixed with the WordPress * table prefix if not already present. diff --git a/tests/phpunit/QueryBuilderTest.php b/tests/phpunit/DBTest.php similarity index 80% rename from tests/phpunit/QueryBuilderTest.php rename to tests/phpunit/DBTest.php index ced64a85e4..d4f6677766 100644 --- a/tests/phpunit/QueryBuilderTest.php +++ b/tests/phpunit/DBTest.php @@ -1,6 +1,6 @@ prefix; // Without prefix. - $builder = QueryBuilder::table( 'tutor_orders' ); + $builder = DB::table( 'tutor_orders' ); $this->assertSame( "SELECT * FROM {$prefix}tutor_orders", $builder->to_sql() ); // With prefix already included. - $builder2 = QueryBuilder::table( "{$prefix}tutor_orders" ); + $builder2 = DB::table( "{$prefix}tutor_orders" ); $this->assertSame( "SELECT * FROM {$prefix}tutor_orders", $builder2->to_sql() ); } @@ -45,7 +45,7 @@ public function test_table_alias() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->as( 'o' ) ->to_sql(); @@ -61,7 +61,7 @@ public function test_select_columns() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->select( 'id', 'order_status', 'total_price' ) ->to_sql(); @@ -77,7 +77,7 @@ public function test_select_raw_and_distinct() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->distinct() ->select( 'order_status' ) ->select_raw( 'COUNT(*) AS total' ) @@ -95,7 +95,7 @@ public function test_joins() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->as( 'o' ) ->select( 'o.id', 'u.user_login' ) ->join( "{$wpdb->users} AS u", 'o.user_id', '=', 'u.ID' ) @@ -115,7 +115,7 @@ public function test_where_conditions() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->where( 'order_status', 'completed' ) ->where( 'total_price', '>', 50 ) ->where( 'user_id', '=', 10 ) @@ -134,7 +134,7 @@ public function test_or_where_conditions() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->where( 'order_status', 'completed' ) ->or_where( 'order_status', 'processing' ) ->to_sql(); @@ -152,7 +152,7 @@ public function test_where_in_and_not_in() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->where_in( 'order_status', array( 'completed', 'pending' ) ) ->where_not_in( 'user_id', array( 1, 2, 3 ) ) ->to_sql(); @@ -170,7 +170,7 @@ public function test_where_null_and_not_null() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->where_null( 'deleted_at' ) ->where_not_null( 'completed_at' ) ->to_sql(); @@ -188,7 +188,7 @@ public function test_where_between() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->where_between( 'created_at', '2024-01-01', '2024-12-31' ) ->where_not_between( 'total_price', 10, 20 ) ->to_sql(); @@ -206,7 +206,7 @@ public function test_where_like() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'posts' ) + $sql = DB::table( 'posts' ) ->where_like( 'post_title', 'WordPress' ) ->to_sql(); @@ -223,7 +223,7 @@ public function test_where_raw() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->where_raw( 'DATE(created_at) = %s', array( '2024-05-01' ) ) ->or_where_raw( 'id = %d', array( 99 ) ) ->to_sql(); @@ -241,7 +241,7 @@ public function test_group_having_order() { global $wpdb; $prefix = $wpdb->prefix; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->select( 'order_status' ) ->select_raw( 'COUNT(*) AS total' ) ->group_by( 'order_status' ) @@ -264,10 +264,10 @@ public function test_latest_and_oldest() { global $wpdb; $prefix = $wpdb->prefix; - $latest = QueryBuilder::table( 'posts' )->latest()->to_sql(); + $latest = DB::table( 'posts' )->latest()->to_sql(); $this->assertSame( "SELECT * FROM {$prefix}posts ORDER BY id DESC", $latest ); - $oldest = QueryBuilder::table( 'posts' )->oldest( 'post_date' )->to_sql(); + $oldest = DB::table( 'posts' )->oldest( 'post_date' )->to_sql(); $this->assertSame( "SELECT * FROM {$prefix}posts ORDER BY post_date ASC", $oldest ); } @@ -282,7 +282,7 @@ public function test_when_helper() { $prefix = $wpdb->prefix; $search = 'active'; - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->when( $search, function ( $query, $value ) { @@ -306,12 +306,12 @@ function ( $query ) { * @return void */ public function test_prepare_value() { - $this->assertSame( '42', QueryBuilder::prepare_value( 42 ) ); - $this->assertSame( "'hello'", QueryBuilder::prepare_value( 'hello' ) ); - $this->assertSame( '1', QueryBuilder::prepare_value( true ) ); - $this->assertSame( '0', QueryBuilder::prepare_value( false ) ); - $this->assertSame( 'NULL', QueryBuilder::prepare_value( null ) ); - $this->assertSame( '19.99', QueryBuilder::prepare_value( 19.99 ) ); + $this->assertSame( '42', DB::prepare_value( 42 ) ); + $this->assertSame( "'hello'", DB::prepare_value( 'hello' ) ); + $this->assertSame( '1', DB::prepare_value( true ) ); + $this->assertSame( '0', DB::prepare_value( false ) ); + $this->assertSame( 'NULL', DB::prepare_value( null ) ); + $this->assertSame( '19.99', DB::prepare_value( 19.99 ) ); } /** @@ -320,7 +320,7 @@ public function test_prepare_value() { * @return void */ public function test_prepare_in_clause() { - $in = QueryBuilder::prepare_in_clause( array( 'apple', 123, 'banana' ) ); + $in = DB::prepare_in_clause( array( 'apple', 123, 'banana' ) ); $this->assertSame( "'apple', 123, 'banana'", $in ); } @@ -335,7 +335,7 @@ public function test_identifier_sanitization() { $prefix = $wpdb->prefix; // Malicious column attempt should be ignored. - $sql = QueryBuilder::table( 'tutor_orders' ) + $sql = DB::table( 'tutor_orders' ) ->select( 'id', 'status; DROP TABLE tutor_orders; --' ) ->where( '1=1; --', 'val' ) ->to_sql(); @@ -351,7 +351,7 @@ public function test_identifier_sanitization() { public function test_database_crud_operations() { // 1. Insert single row. $unique_key = 'qb_test_' . time() . '_' . wp_rand( 1000, 9999 ); - $insert_id = QueryBuilder::table( 'options' ) + $insert_id = DB::table( 'options' ) ->insert( array( 'option_name' => $unique_key, @@ -363,44 +363,44 @@ public function test_database_crud_operations() { $this->assertGreaterThan( 0, $insert_id ); // 2. exists(). - $exists = QueryBuilder::table( 'options' ) + $exists = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->exists(); $this->assertTrue( $exists ); // 3. find() by primary key. - $row = QueryBuilder::table( 'options' )->find( $insert_id, 'option_id' ); + $row = DB::table( 'options' )->find( $insert_id, 'option_id' ); $this->assertNotNull( $row ); $this->assertSame( $unique_key, $row->option_name ); $this->assertSame( 'initial_value', $row->option_value ); // 4. first(). - $first_row = QueryBuilder::table( 'options' ) + $first_row = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->first( 'ARRAY_A' ); $this->assertIsArray( $first_row ); $this->assertSame( 'initial_value', $first_row['option_value'] ); // 5. value(). - $val = QueryBuilder::table( 'options' ) + $val = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->value( 'option_value' ); $this->assertSame( 'initial_value', $val ); // 6. count(). - $count = QueryBuilder::table( 'options' ) + $count = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->count(); $this->assertSame( 1, $count ); // 7. pluck(). - $plucked = QueryBuilder::table( 'options' ) + $plucked = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->pluck( 'option_value' ); $this->assertSame( array( 'initial_value' ), $plucked ); // 8. update(). - $updated = QueryBuilder::table( 'options' ) + $updated = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->update( array( @@ -409,18 +409,18 @@ public function test_database_crud_operations() { ); $this->assertTrue( $updated ); - $new_val = QueryBuilder::table( 'options' ) + $new_val = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->value( 'option_value' ); $this->assertSame( 'updated_value', $new_val ); // 9. delete(). - $deleted = QueryBuilder::table( 'options' ) + $deleted = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->delete(); $this->assertSame( 1, $deleted ); - $after_delete_exists = QueryBuilder::table( 'options' ) + $after_delete_exists = DB::table( 'options' ) ->where( 'option_name', $unique_key ) ->exists(); $this->assertFalse( $after_delete_exists ); From 52f355b40baf69687efe40b2e4092da379677e37 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Thu, 1 Oct 2026 17:39:18 +0600 Subject: [PATCH 038/218] chore: update @since version tags to 4.1.2 --- helpers/DB.php | 178 +++++++++++++++++++-------------------- helpers/QueryHelper.php | 5 +- tests/phpunit/DBTest.php | 2 +- 3 files changed, 93 insertions(+), 92 deletions(-) diff --git a/helpers/DB.php b/helpers/DB.php index b1565e913b..85d5f2a11a 100644 --- a/helpers/DB.php +++ b/helpers/DB.php @@ -7,7 +7,7 @@ * consistent use of $wpdb->prepare(). * * @package Tutor\Helpers - * @since 4.2.0 + * @since 4.1.2 */ namespace Tutor\Helpers; @@ -26,14 +26,14 @@ * ->limit(10) * ->get(); * - * @since 4.2.0 + * @since 4.1.2 */ class DB { /** * Table name (with prefix). * - * @since 4.2.0 + * @since 4.1.2 * * @var string */ @@ -42,7 +42,7 @@ class DB { /** * Table alias. * - * @since 4.2.0 + * @since 4.1.2 * * @var string */ @@ -51,7 +51,7 @@ class DB { /** * Columns to select. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -60,7 +60,7 @@ class DB { /** * Raw select expressions. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -69,7 +69,7 @@ class DB { /** * Whether to apply DISTINCT. * - * @since 4.2.0 + * @since 4.1.2 * * @var bool */ @@ -78,7 +78,7 @@ class DB { /** * JOIN clauses. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -89,7 +89,7 @@ class DB { * * Each entry: [ 'sql' => string, 'boolean' => 'AND'|'OR' ]. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -98,7 +98,7 @@ class DB { /** * GROUP BY columns. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -107,7 +107,7 @@ class DB { /** * HAVING clause conditions. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -116,7 +116,7 @@ class DB { /** * ORDER BY clauses. * - * @since 4.2.0 + * @since 4.1.2 * * @var array */ @@ -125,7 +125,7 @@ class DB { /** * LIMIT value. * - * @since 4.2.0 + * @since 4.1.2 * * @var int|null */ @@ -134,7 +134,7 @@ class DB { /** * OFFSET value. * - * @since 4.2.0 + * @since 4.1.2 * * @var int */ @@ -143,7 +143,7 @@ class DB { /** * Supported comparison operators. * - * @since 4.2.0 + * @since 4.1.2 * * @var string[] */ @@ -175,7 +175,7 @@ class DB { * The table name will be automatically prefixed with the WordPress * table prefix if not already present. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $table Table name (without prefix is fine). * @@ -197,7 +197,7 @@ public static function table( string $table ): self { * * Accepts one or more column names. Multiple calls are additive. * - * @since 4.2.0 + * @since 4.1.2 * * @param string ...$columns Column names to select. * @@ -219,7 +219,7 @@ public function select( string ...$columns ): self { * Use this for aggregate functions, computed columns, or any SQL * expression that cannot be represented as a simple column name. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $expression Raw SQL expression, e.g., 'COUNT(*) AS total'. * @param array $bindings Optional values to bind to placeholders. @@ -241,7 +241,7 @@ public function select_raw( string $expression, array $bindings = array() ): sel /** * Apply DISTINCT to the query. * - * @since 4.2.0 + * @since 4.1.2 * * @return self */ @@ -254,7 +254,7 @@ public function distinct(): self { /** * Set a table alias. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $alias Alias for the primary table. * @@ -275,7 +275,7 @@ public function as( string $alias ): self { /** * Add an INNER JOIN clause. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $table Table to join (may include alias, e.g., 'users AS u'). * @param string $first First column in the ON condition. @@ -291,7 +291,7 @@ public function join( string $table, string $first, string $operator, string $se /** * Add a LEFT JOIN clause. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $table Table to join. * @param string $first First column. @@ -307,7 +307,7 @@ public function left_join( string $table, string $first, string $operator, strin /** * Add a RIGHT JOIN clause. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $table Table to join. * @param string $first First column. @@ -326,7 +326,7 @@ public function right_join( string $table, string $first, string $operator, stri * Validates join type, table alias, columns, and operator before * adding to the query. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $type Join type (INNER, LEFT, RIGHT, CROSS). * @param string $table Table name, may include alias e.g., 'users AS u'. @@ -376,7 +376,7 @@ private function add_join( string $type, string $table, string $first, string $o * Three-argument form uses custom operator: where('col', '>=', 5). * Passing null as value auto-converts to IS NULL / IS NOT NULL. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $operator_or_value Operator (if 3 args) or value (if 2 args). @@ -391,7 +391,7 @@ public function where( string $column, $operator_or_value, $value = null ): self /** * Add an OR WHERE condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $operator_or_value Operator or value. @@ -406,7 +406,7 @@ public function or_where( string $column, $operator_or_value, $value = null ): s /** * Add a WHERE IN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param array $values Array of values. @@ -420,7 +420,7 @@ public function where_in( string $column, array $values ): self { /** * Add a WHERE NOT IN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param array $values Array of values. @@ -434,7 +434,7 @@ public function where_not_in( string $column, array $values ): self { /** * Add an OR WHERE IN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param array $values Array of values. @@ -448,7 +448,7 @@ public function or_where_in( string $column, array $values ): self { /** * Add an OR WHERE NOT IN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param array $values Array of values. @@ -462,7 +462,7 @@ public function or_where_not_in( string $column, array $values ): self { /** * Add a WHERE IS NULL condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @@ -482,7 +482,7 @@ public function where_null( string $column ): self { /** * Add a WHERE IS NOT NULL condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @@ -502,7 +502,7 @@ public function where_not_null( string $column ): self { /** * Add an OR WHERE IS NULL condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @@ -522,7 +522,7 @@ public function or_where_null( string $column ): self { /** * Add an OR WHERE IS NOT NULL condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @@ -542,7 +542,7 @@ public function or_where_not_null( string $column ): self { /** * Add a WHERE BETWEEN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $min Minimum value. @@ -557,7 +557,7 @@ public function where_between( string $column, $min, $max ): self { /** * Add a WHERE NOT BETWEEN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $min Minimum value. @@ -572,7 +572,7 @@ public function where_not_between( string $column, $min, $max ): self { /** * Add an OR WHERE BETWEEN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $min Minimum value. @@ -587,7 +587,7 @@ public function or_where_between( string $column, $min, $max ): self { /** * Add an OR WHERE NOT BETWEEN condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $min Minimum value. @@ -605,7 +605,7 @@ public function or_where_not_between( string $column, $min, $max ): self { * The value is automatically wrapped with '%' wildcards and escaped * using $wpdb->esc_like() to prevent LIKE injection. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param string $value Search term (auto-wrapped with % wildcards). @@ -619,7 +619,7 @@ public function where_like( string $column, string $value ): self { /** * Add a WHERE NOT LIKE condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param string $value Search term. @@ -633,7 +633,7 @@ public function where_not_like( string $column, string $value ): self { /** * Add an OR WHERE LIKE condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param string $value Search term. @@ -647,7 +647,7 @@ public function or_where_like( string $column, string $value ): self { /** * Add an OR WHERE NOT LIKE condition. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param string $value Search term. @@ -664,7 +664,7 @@ public function or_where_not_like( string $column, string $value ): self { * Use $wpdb->prepare() format specifiers (%s, %d, %f) in the expression * and pass corresponding values in the bindings array. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $expression Raw SQL expression, e.g., 'DATE(col) = %s'. * @param array $bindings Optional values to bind. @@ -678,7 +678,7 @@ public function where_raw( string $expression, array $bindings = array() ): self /** * Add a raw OR WHERE expression. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $expression Raw SQL expression. * @param array $bindings Optional values to bind. @@ -696,7 +696,7 @@ public function or_where_raw( string $expression, array $bindings = array() ): s /** * Add GROUP BY columns. * - * @since 4.2.0 + * @since 4.1.2 * * @param string ...$columns Column names to group by. * @@ -719,7 +719,7 @@ public function group_by( string ...$columns ): self { /** * Add a raw HAVING expression. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $expression Raw SQL expression, e.g., 'COUNT(*) > %d'. * @param array $bindings Optional values to bind. @@ -747,7 +747,7 @@ public function having_raw( string $expression, array $bindings = array() ): sel * * Multiple calls add multiple sort columns. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param string $direction ASC or DESC, default ASC. @@ -768,7 +768,7 @@ public function order_by( string $column, string $direction = 'ASC' ): self { /** * Add a raw ORDER BY expression. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $expression Raw SQL expression. * @param array $bindings Optional values to bind. @@ -790,7 +790,7 @@ public function order_by_raw( string $expression, array $bindings = array() ): s /** * Order results by the given column descending (default 'id'). * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name, default 'id'. * @@ -803,7 +803,7 @@ public function latest( string $column = 'id' ): self { /** * Order results by the given column ascending (default 'id'). * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name, default 'id'. * @@ -820,7 +820,7 @@ public function oldest( string $column = 'id' ): self { /** * Set the query LIMIT. * - * @since 4.2.0 + * @since 4.1.2 * * @param int $limit Number of rows to return. * @@ -835,7 +835,7 @@ public function limit( int $limit ): self { /** * Set the query OFFSET. * - * @since 4.2.0 + * @since 4.1.2 * * @param int $offset Number of rows to skip. * @@ -854,7 +854,7 @@ public function offset( int $offset ): self { /** * Execute the query and return all matching rows. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $output Output type: OBJECT, ARRAY_A, ARRAY_N. * @@ -874,7 +874,7 @@ public function get( string $output = 'OBJECT' ): array { /** * Execute the query and return the first matching row. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $output Output type: OBJECT, ARRAY_A, ARRAY_N. * @@ -898,7 +898,7 @@ public function first( string $output = 'OBJECT' ) { /** * Get the count of rows matching the query. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column to count, default '*'. * @@ -944,7 +944,7 @@ public function count( string $column = '*' ): int { /** * Execute the query with pagination. * - * @since 4.2.0 + * @since 4.1.2 * * @param int $per_page Results per page. * @param int $current_page Current page number (1-indexed). @@ -989,7 +989,7 @@ public function paginate( int $per_page, int $current_page = 1, string $output = /** * Get a single column value from the first matching row. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name to retrieve. * @@ -1029,7 +1029,7 @@ public function value( string $column ) { * If a key column is provided, the resulting array will use that column's * values as array keys. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column value to retrieve. * @param string|null $key Optional column to use as array key. @@ -1074,7 +1074,7 @@ public function pluck( string $column, ?string $key = null ): array { /** * Determine if any matching records exist for the current query. * - * @since 4.2.0 + * @since 4.1.2 * * @return bool True if at least one matching record exists. */ @@ -1085,7 +1085,7 @@ public function exists(): bool { /** * Find a single record by its primary key. * - * @since 4.2.0 + * @since 4.1.2 * * @param int|string $id Primary key value. * @param string $primary_key Primary key column name, default 'id'. @@ -1100,7 +1100,7 @@ public function find( $id, string $primary_key = 'id', string $output = 'OBJECT' /** * Apply the given callback if the condition is truthy. * - * @since 4.2.0 + * @since 4.1.2 * * @param mixed $condition Truthy/falsy condition. * @param callable $callback Callback receiving ($this, $condition). @@ -1123,7 +1123,7 @@ public function when( $condition, callable $callback, ?callable $default_callbac * * Uses $wpdb->insert() which automatically handles escaping. * - * @since 4.2.0 + * @since 4.1.2 * * @param array $data Assoc array of column => value pairs. * @param array $format Optional format specifiers (%s, %d, %f) for each value. @@ -1151,7 +1151,7 @@ public function insert( array $data, array $format = array() ): int { * All rows must have the same columns. Values are individually escaped * using $wpdb->prepare(). NULL values are inserted as SQL NULL. * - * @since 4.2.0 + * @since 4.1.2 * * @param array $rows Array of assoc arrays, each representing a row. * @@ -1201,7 +1201,7 @@ public function insert_multiple( array $rows ): bool { * All SET values are individually escaped using $wpdb->prepare(). * NULL values produce `SET column = NULL`. * - * @since 4.2.0 + * @since 4.1.2 * * @param array $data Assoc array of column => value pairs to update. * @@ -1252,7 +1252,7 @@ public function update( array $data ): bool { * WARNING: Calling delete() without any where() conditions will * delete ALL rows from the table. * - * @since 4.2.0 + * @since 4.1.2 * * @throws \Exception If a database error occurs. * @@ -1293,7 +1293,7 @@ public function delete(): int { * Useful for debugging and logging. Returns the SELECT query as it * would be sent to the database. * - * @since 4.2.0 + * @since 4.1.2 * * @return string The generated SQL string. */ @@ -1319,7 +1319,7 @@ public function to_sql(): string { * Handles two-argument (default '=') and three-argument (custom operator) * forms. Null values are auto-converted to IS NULL / IS NOT NULL. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $operator_or_value Operator or value. @@ -1372,7 +1372,7 @@ private function add_where( string $column, $operator_or_value, $value, string $ /** * Internal method to add WHERE IN / NOT IN conditions. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param array $values Array of values. @@ -1399,7 +1399,7 @@ private function add_where_in( string $column, array $values, string $type, stri /** * Internal method for BETWEEN / NOT BETWEEN conditions. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param mixed $min Minimum value. @@ -1430,7 +1430,7 @@ private function add_where_between( string $column, $min, $max, string $type, st * * Escapes the value with $wpdb->esc_like() and wraps with '%' wildcards. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column name. * @param string $value Search term. @@ -1460,7 +1460,7 @@ private function add_where_like( string $column, string $value, string $type, st /** * Internal method for raw WHERE expressions. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $expression Raw SQL expression. * @param array $bindings Values to bind. @@ -1490,7 +1490,7 @@ private function add_where_raw( string $expression, array $bindings, string $boo /** * Build the complete SELECT query string. * - * @since 4.2.0 + * @since 4.1.2 * * @param bool $calc_found_rows Whether to include SQL_CALC_FOUND_ROWS. * @@ -1549,7 +1549,7 @@ private function build_select_query( bool $calc_found_rows = false ): string { /** * Build SELECT column list. * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1562,7 +1562,7 @@ private function build_select_sql(): string { /** * Build FROM clause with optional alias. * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1577,7 +1577,7 @@ private function build_from_sql(): string { /** * Build JOIN clauses. * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1591,7 +1591,7 @@ private function build_join_sql(): string { * Joins conditions with their respective boolean operators (AND/OR). * The first condition's boolean is always omitted. * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1615,7 +1615,7 @@ private function build_where_sql(): string { /** * Build GROUP BY clause (without the GROUP BY keywords). * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1626,7 +1626,7 @@ private function build_group_by_sql(): string { /** * Build HAVING clause (without the HAVING keyword). * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1637,7 +1637,7 @@ private function build_having_sql(): string { /** * Build ORDER BY clause (without the ORDER BY keywords). * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1648,7 +1648,7 @@ private function build_order_by_sql(): string { /** * Build LIMIT/OFFSET clause. * - * @since 4.2.0 + * @since 4.1.2 * * @return string */ @@ -1669,7 +1669,7 @@ private function build_limit_sql(): string { * * Allows: column, table.column, t.*, *, _col, col123, etc. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $column Column identifier. * @@ -1682,7 +1682,7 @@ private static function is_valid_column( string $column ): bool { /** * Validate a SQL identifier (table name, alias). * - * @since 4.2.0 + * @since 4.1.2 * * @param string $identifier Identifier to validate. * @@ -1695,7 +1695,7 @@ private static function is_valid_identifier( string $identifier ): bool { /** * Check whether a comparison operator is supported. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $operator SQL operator. * @@ -1708,7 +1708,7 @@ private static function is_valid_operator( string $operator ): bool { /** * Get the wpdb format placeholder for a value based on its PHP type. * - * @since 4.2.0 + * @since 4.1.2 * * @param mixed $value Value to determine placeholder for. * @@ -1736,7 +1736,7 @@ private static function get_placeholder( $value ): string { * Numbers and booleans are formatted accordingly; strings are escaped and quoted. * NULL is returned as the unquoted literal string 'NULL'. * - * @since 4.2.0 + * @since 4.1.2 * * @param mixed $value Value to prepare. * @@ -1774,7 +1774,7 @@ public static function prepare_value( $value ): string { /** * Make sanitized SQL IN clause values from an array. * - * @since 4.2.0 + * @since 4.1.2 * * @param array $values Array of scalar values. * @@ -1794,7 +1794,7 @@ function ( $value ) { /** * Prepare table name with WordPress prefix if not already prefixed. * - * @since 4.2.0 + * @since 4.1.2 * * @param string $table Table name. * @@ -1814,7 +1814,7 @@ public static function prepare_table_name( string $table ): string { /** * Get the WordPress database table prefix. * - * @since 4.2.0 + * @since 4.1.2 * * @return string Table prefix. */ @@ -1827,7 +1827,7 @@ public static function get_table_prefix(): string { /** * Get the last executed query from $wpdb. * - * @since 4.2.0 + * @since 4.1.2 * * @return string Last executed SQL query. */ diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 41b7e4d8f1..222504f8ff 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -10,8 +10,9 @@ namespace Tutor\Helpers; /** - * Do the common db operations through helper - * methods + * Do the common db operations through helper methods. + * + * @deprecated since 4.1.2. Use \Tutor\Helpers\DB instead. */ class QueryHelper { diff --git a/tests/phpunit/DBTest.php b/tests/phpunit/DBTest.php index d4f6677766..935ce2288b 100644 --- a/tests/phpunit/DBTest.php +++ b/tests/phpunit/DBTest.php @@ -3,7 +3,7 @@ * DB Class Unit Test * * @package Tutor\Test - * @since 4.2.0 + * @since 4.1.2 */ namespace TutorTest; From 689ace19c3452fc77836d3673d0fd5a4fd1c8f55 Mon Sep 17 00:00:00 2001 From: Sanjana Khan Date: Fri, 2 Oct 2026 04:27:30 +0600 Subject: [PATCH 039/218] Paypal fatal error fix for recurring payment. --- ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index 784f9a74e1..bbc7db78c0 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -274,7 +274,7 @@ public function createRecurringPayment() { } catch ( HttpRequestException $error ) { $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); - error_log( 'Paypal Error: ', $error_message ); + error_log( 'Paypal Error: ' . $error_message ); throw new ErrorException( esc_html__( 'Something Went Wrong', 'tutor' ) ); } } From 9420da10e47ed1743686d658d7745b2ae43e9bf7 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Fri, 2 Oct 2026 10:52:33 +0600 Subject: [PATCH 040/218] Version tag added --- .../src/Contracts/Support/RequestContract.php | 13 +++++++ .../Contracts/Support/ResponseContract.php | 21 +++++++++++ .../Paypal/src/Core/Support/Request.php | 37 ++++++++++++++++++- .../Paypal/src/Core/Support/Response.php | 26 +++++++++++++ .../Paypal/src/Core/Support/System.php | 3 +- .../src/Exceptions/HttpRequestException.php | 10 +++++ .../Paypal/src/Payments/Paypal/Api.php | 2 + .../Paypal/src/Payments/Paypal/Helper.php | 1 + .../Paypal/src/Payments/Paypal/Paypal.php | 6 +++ 9 files changed, 116 insertions(+), 3 deletions(-) diff --git a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php index 10e9960f21..6fbf42fc15 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php +++ b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php @@ -3,11 +3,18 @@ use Ollyo\PaymentHub\Exceptions\HttpRequestException; +/** + * Contract for an HTTP request sent by the payment gateways. + * + * @since 4.1.2 + */ interface RequestContract { /** * Send the request. * + * @since 4.1.2 + * * @return ResponseContract * @throws HttpRequestException On a transport error, or on a 4xx/5xx status unless http_errors is false. */ @@ -16,6 +23,8 @@ public function send(): ResponseContract; /** * Get the HTTP method in uppercase. * + * @since 4.1.2 + * * @return string */ public function get_method(): string; @@ -23,6 +32,8 @@ public function get_method(): string; /** * Get the request URL, without the query option applied. * + * @since 4.1.2 + * * @return string */ public function get_url(): string; @@ -30,6 +41,8 @@ public function get_url(): string; /** * Get the request options. * + * @since 4.1.2 + * * @return array */ public function get_options(): array; diff --git a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/ResponseContract.php b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/ResponseContract.php index b023b675fc..a900bb92cc 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/ResponseContract.php +++ b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/ResponseContract.php @@ -1,11 +1,18 @@ Date: Fri, 2 Oct 2026 11:23:26 +0600 Subject: [PATCH 041/218] developer doc added for DB helper --- docs/developer/helpers/DB.php.md | 465 +++++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 docs/developer/helpers/DB.php.md diff --git a/docs/developer/helpers/DB.php.md b/docs/developer/helpers/DB.php.md new file mode 100644 index 0000000000..e6fc801d9c --- /dev/null +++ b/docs/developer/helpers/DB.php.md @@ -0,0 +1,465 @@ +# DB Helper (`Tutor\Helpers\DB`) + +- **Class:** `Tutor\Helpers\DB` +- **File:** `wp-content/plugins/tutor/helpers/DB.php` +- **Namespace:** `Tutor\Helpers` +- **Since:** `4.2.0` +- **Package:** `Tutor\Helpers` + +--- + +## Overview + +The `DB` class provides a fluent, chainable query builder interface for WordPress database operations. Inspired by Laravel's Query Builder, it simplifies complex query construction while guaranteeing strict SQL injection prevention through automatic parameter binding via `$wpdb->prepare()` and strict regex validation on SQL identifiers. + +### Key Features + +- **Fluent API:** Clean, readable, chainable query building. +- **SQL Injection Prevention:** All values are bound through `$wpdb->prepare()`. Table names and column identifiers are validated against strict regex patterns. +- **Automatic Table Prefixing:** Auto-detects and prepends `$wpdb->prefix` when omitted. +- **Self-Contained & Independent:** Zero external dependencies beyond WordPress `$wpdb`. +- **Full Query Lifecycle:** Supports `SELECT`, `INSERT`, `UPDATE`, `DELETE`, aggregates, joins, and built-in pagination. + +--- + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [API Synopsis](#api-synopsis) +3. [Query Initialization](#query-initialization) +4. [Select Clauses](#select-clauses) +5. [Joins](#joins) +6. [Where Clauses](#where-clauses) +7. [Ordering & Grouping](#ordering--grouping) +8. [Conditional Queries (`when`)](#conditional-queries-when) +9. [Retrieving Data](#retrieving-data) +10. [Pagination](#pagination) +11. [Data Mutations (CRUD)](#data-mutations-crud) +12. [Debugging & Raw Queries](#debugging--raw-queries) +13. [Security Architecture](#security-architecture) + +--- + +## Quick Start + +```php +use Tutor\Helpers\DB; + +// Fetch active courses with instructor names +$courses = DB::table('posts') + ->as('p') + ->select('p.ID', 'p.post_title', 'u.display_name AS instructor') + ->join("{$wpdb->users} AS u", 'p.post_author', '=', 'u.ID') + ->where('p.post_type', 'courses') + ->where('p.post_status', 'publish') + ->order_by('p.ID', 'DESC') + ->limit(10) + ->get(); +``` + +--- + +## API Synopsis + +```php +class DB { + // Factory + public static function table( string $table ): self; + + // Selection & Projection + public function select( string ...$columns ): self; + public function select_raw( string $expression, array $bindings = [] ): self; + public function distinct(): self; + public function as( string $alias ): self; + + // Joins + public function join( string $table, string $first, string $operator, string $second ): self; + public function left_join( string $table, string $first, string $operator, string $second ): self; + public function right_join( string $table, string $first, string $operator, string $second ): self; + + // Where Clauses + public function where( string $column, $operator_or_val, $val = null ): self; + public function or_where( string $column, $operator_or_val, $val = null ): self; + public function where_in( string $column, array $values ): self; + public function or_where_in( string $column, array $values ): self; + public function where_not_in( string $column, array $values ): self; + public function or_where_not_in( string $column, array $values ): self; + public function where_null( string $column ): self; + public function or_where_null( string $column ): self; + public function where_not_null( string $column ): self; + public function or_where_not_null( string $column ): self; + public function where_between( string $column, $min, $max ): self; + public function or_where_between( string $column, $min, $max ): self; + public function where_not_between( string $column, $min, $max ): self; + public function or_where_not_between( string $column, $min, $max ): self; + public function where_like( string $column, string $value ): self; + public function or_where_like( string $column, string $value ): self; + public function where_not_like( string $column, string $value ): self; + public function or_where_not_like( string $column, string $value ): self; + public function where_raw( string $expression, array $bindings = [] ): self; + public function or_where_raw( string $expression, array $bindings = [] ): self; + + // Grouping & Ordering + public function group_by( string ...$columns ): self; + public function having_raw( string $expression, array $bindings = [] ): self; + public function order_by( string $column, string $direction = 'ASC' ): self; + public function order_by_raw( string $expression, array $bindings = [] ): self; + public function latest( string $column = 'id' ): self; + public function oldest( string $column = 'id' ): self; + + // Pagination & Limits + public function limit( int $limit ): self; + public function offset( int $offset ): self; + public function paginate( int $per_page, int $current_page = 1, string $output = 'OBJECT' ): array; + + // Conditionals + public function when( $condition, callable $callback, ?callable $default = null ): self; + + // Retrieval + public function get( string $output = 'OBJECT' ): array; + public function first( string $output = 'OBJECT' ); + public function find( $id, string $primary_key = 'id', string $output = 'OBJECT' ); + public function value( string $column ); + public function pluck( string $column, ?string $key = null ): array; + public function count( string $column = '*' ): int; + public function exists(): bool; + + // Mutations + public function insert( array $data, array $format = [] ): int; + public function insert_multiple( array $rows ): bool; + public function update( array $data ): bool; + public function delete(): int; + + // Debug & Inspection + public function to_sql(): string; + + // Utility Helpers + public static function prepare_value( $value ): string; + public static function prepare_in_clause( array $values ): string; + public static function prepare_table_name( string $table ): string; + public static function get_table_prefix(): string; + public static function get_last_query(): string; +} +``` + +--- + +## Query Initialization + +### `table( string $table ): self` +Initializes a new query builder instance. The table name is automatically prefixed with `$wpdb->prefix` if not already present. + +```php +// Both yield: wp_tutor_orders (assuming prefix is 'wp_') +DB::table('tutor_orders'); +DB::table('wp_tutor_orders'); +``` + +### `as( string $alias ): self` +Assigns an alias to the main query table. + +```php +DB::table('tutor_orders')->as('o'); +// FROM wp_tutor_orders AS o +``` + +--- + +## Select Clauses + +### `select( string ...$columns ): self` +Specifies which columns to retrieve. Multiple calls append columns. + +```php +DB::table('users')->select('ID', 'user_email'); +``` + +### `select_raw( string $expression, array $bindings = [] ): self` +Injects raw SQL expressions into the `SELECT` clause (e.g., aggregate functions). + +```php +DB::table('tutor_orders') + ->select_raw('COUNT(*) as total_orders, AVG(total_price) as avg_price') + ->get(); +``` + +### `distinct(): self` +Forces the query to return distinct results. + +```php +DB::table('posts')->distinct()->select('post_type')->get(); +``` + +--- + +## Joins + +Joins accept: +1. Target table (optionally with alias: `'users AS u'`) +2. Left column +3. Operator (`=`, `>`, etc.) +4. Right column + +### Supported Join Methods: +- `join( $table, $first, $operator, $second )` — `INNER JOIN` +- `left_join( $table, $first, $operator, $second )` — `LEFT JOIN` +- `right_join( $table, $first, $operator, $second )` — `RIGHT JOIN` + +```php +DB::table('tutor_earnings') + ->as('e') + ->select('e.*', 'u.user_login') + ->left_join("{$wpdb->users} AS u", 'e.user_id', '=', 'u.ID') + ->get(); +``` + +--- + +## Where Clauses + +### 1. Basic Where +Supports both 2-argument (assumes `=`) and 3-argument syntax. + +```php +DB::table('users')->where('user_status', 0); // user_status = 0 +DB::table('tutor_orders')->where('total_price', '>=', 100); // total_price >= 100 +DB::table('users')->or_where('user_login', 'admin'); // OR user_login = 'admin' +``` + +### 2. IN / NOT IN +```php +DB::table('posts')->where_in('ID', [10, 20, 30]); +DB::table('posts')->where_not_in('post_status', ['trash', 'auto-draft']); +DB::table('posts')->or_where_in('ID', [40, 50]); +DB::table('posts')->or_where_not_in('post_type', ['attachment']); +``` + +### 3. NULL / NOT NULL +```php +DB::table('posts')->where_null('post_parent'); +DB::table('posts')->where_not_null('post_password'); +DB::table('posts')->or_where_null('post_excerpt'); +DB::table('posts')->or_where_not_null('to_ping'); +``` + +### 4. BETWEEN +```php +DB::table('tutor_orders')->where_between('created_at', '2024-01-01', '2024-12-31'); +DB::table('tutor_orders')->where_not_between('total_price', 10, 50); +``` + +### 5. LIKE +Automatically wraps the search term with `%` wildcards and escapes internal characters using `$wpdb->esc_like()` to prevent LIKE-injection vulnerabilities. + +```php +DB::table('posts')->where_like('post_title', 'WordPress'); +// Generates: post_title LIKE '%WordPress%' + +DB::table('posts')->where_not_like('post_content', 'deprecated'); +``` + +### 6. Raw WHERE +For advanced or database-native functions. Use `$wpdb->prepare()` placeholders (`%s`, `%d`, `%f`) in expressions. + +```php +DB::table('tutor_orders') + ->where_raw('DATE(created_at) = CURDATE()') + ->or_where_raw('TIMESTAMPDIFF(DAY, created_at, NOW()) <= %d', [7]); +``` + +--- + +## Ordering & Grouping + +```php +// Standard Ordering +DB::table('posts')->order_by('post_date', 'DESC'); + +// Shortcuts +DB::table('posts')->latest(); // ORDER BY id DESC +DB::table('posts')->oldest('post_date'); // ORDER BY post_date ASC + +// Raw Order By (e.g. MySQL FIELD) +DB::table('posts')->order_by_raw('FIELD(post_status, %s, %s, %s)', ['publish', 'draft', 'pending']); + +// Group By & Having +DB::table('tutor_quiz_attempts') + ->select('quiz_id') + ->select_raw('COUNT(*) AS total_attempts') + ->group_by('quiz_id') + ->having_raw('COUNT(*) >= %d', [10]) + ->get(); +``` + +--- + +## Conditional Queries (`when`) + +Applies constraints conditionally without breaking fluent chaining: + +```php +$search = sanitize_text_field( $_GET['search'] ?? '' ); +$order_by = sanitize_key( $_GET['order_by'] ?? '' ); + +$results = DB::table('posts') + ->where('post_type', 'courses') + ->when($search, function ($query, $term) { + $query->where_like('post_title', $term); + }) + ->when($order_by, function ($query, $column) { + $query->order_by($column, 'ASC'); + }, function ($query) { + $query->latest(); // Default fallback when false + }) + ->get(); +``` + +--- + +## Retrieving Data + +| Method | Return Type | Description | +| :--- | :--- | :--- | +| `get( $output )` | `array` | Returns all matching records. Supports `'OBJECT'`, `'ARRAY_A'`, `'ARRAY_N'`. | +| `first( $output )` | `object\|array\|null` | Returns the first matching record (automatically applies `LIMIT 1`). | +| `find( $id, $pk, $output )` | `object\|array\|null` | Finds a record by primary key (default `$pk = 'id'`). | +| `value( $column )` | `mixed\|null` | Retrieves a single field value from the first matching row. | +| `pluck( $column, $key )` | `array` | Extracts a flat list of column values, optionally indexed by `$key`. | +| `count( $column )` | `int` | Returns total row count matching the query. | +| `exists()` | `bool` | Returns `true` if at least one matching record exists. | + +```php +// Fetch as associative array +$user = DB::table('users')->where('ID', 1)->first('ARRAY_A'); + +// Key-value pair array: [ 10 => 'Course Alpha', 12 => 'Course Beta' ] +$course_map = DB::table('posts') + ->where('post_type', 'courses') + ->pluck('post_title', 'ID'); + +// Check existence +if ( DB::table('tutor_enrolments')->where('user_id', 5)->where('course_id', 12)->exists() ) { + // User already enrolled +} +``` + +--- + +## Pagination + +The `paginate()` method handles record fetching, `SQL_CALC_FOUND_ROWS`, and page calculation in a single call: + +```php +$pagination = DB::table('tutor_orders') + ->where('order_status', 'completed') + ->order_by('id', 'DESC') + ->paginate($per_page = 15, $current_page = 1); +``` + +### Response Structure: +```php +[ + 'total_count' => 142, // (int) Total records matching criteria + 'per_page' => 15, // (int) Records per page + 'current_page' => 1, // (int) Active page number + 'total_pages' => 10, // (int) Total calculated pages + 'results' => [ ... ]// (array) Array of result objects +] +``` + +--- + +## Data Mutations (CRUD) + +### 1. Insert +Inserts a single row and returns the new auto-increment ID: + +```php +$insert_id = DB::table('options')->insert([ + 'option_name' => 'tutor_custom_setting', + 'option_value' => 'active', + 'autoload' => 'no', +]); +``` + +### 2. Batch Insert +Bulk inserts multiple rows within a single SQL statement: + +```php +DB::table('tutor_quiz_attempts')->insert_multiple([ + ['user_id' => 10, 'quiz_id' => 45, 'attempt_status' => 'pass'], + ['user_id' => 11, 'quiz_id' => 45, 'attempt_status' => 'fail'], + ['user_id' => 12, 'quiz_id' => 45, 'attempt_status' => 'pass'], +]); +``` + +### 3. Update +Updates rows matching the accumulated `where()` conditions. Returns `true` on success: + +```php +DB::table('users') + ->where('ID', 10) + ->update([ + 'display_name' => 'Jane Doe', + 'user_email' => 'jane@example.com', + ]); +``` + +### 4. Delete +Deletes rows matching the accumulated `where()` conditions. Returns the number of affected rows: + +```php +$deleted_rows = DB::table('options') + ->where('option_name', 'tutor_custom_setting') + ->delete(); +``` + +> [!CAUTION] +> Calling `delete()` without any `where()` conditions will delete **all rows** in the target table. + +--- + +## Debugging & Raw Queries + +### Inspect Compiled SQL (`to_sql`) +Returns the compiled SQL query string with all placeholders resolved, without executing it: + +```php +$sql = DB::table('posts') + ->where('post_type', 'courses') + ->where_in('post_status', ['publish', 'draft']) + ->latest() + ->limit(10) + ->to_sql(); + +// Output: +// SELECT * FROM wp_posts WHERE post_type = 'courses' AND post_status IN ('publish', 'draft') ORDER BY id DESC LIMIT 10 OFFSET 0 +``` + +### Static Utility Methods + +```php +// Table name prefixing +DB::prepare_table_name('tutor_orders'); // "wp_tutor_orders" + +// Escaping single values (numbers, booleans, strings, null) +DB::prepare_value('hello'); // "'hello'" +DB::prepare_value(123); // "123" +DB::prepare_value(null); // "NULL" + +// Escaping list for IN clauses +DB::prepare_in_clause([1, 2, 'text']); // "1, 2, 'text'" + +// Accessors +DB::get_table_prefix(); // "wp_" +DB::get_last_query(); // Returns $wpdb->last_query +``` + +--- + +## Security Architecture + +1. **Prepared Statements:** All literal values passed into `where()`, `where_in()`, `where_between()`, `insert()`, `update()`, and raw bindings are passed through `$wpdb->prepare()`. +2. **Identifier Validation:** Column identifiers and table names are strictly validated against `/^[A-Za-z_*][A-Za-z0-9_.*]*$/`. Characters that could trigger SQL injection (spaces, semicolons, comments, quotes, brackets) are rejected. +3. **LIKE-Injection Protection:** `where_like()` automatically processes input values via `$wpdb->esc_like()`, preventing wildcard abuse (`%` or `_`). +4. **WPCS Compliance:** Follows WordPress Coding Standards (WPCS) with full escaping and cache awareness annotations. From afc91e1bb0f1d68ebfd4b001ee44f8706396afe5 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Fri, 2 Oct 2026 17:09:48 +0600 Subject: [PATCH 042/218] removed new request and response class and used HttpHelper::class --- .../src/Contracts/Support/RequestContract.php | 49 --- .../Contracts/Support/ResponseContract.php | 87 ------ .../Paypal/src/Core/Support/Request.php | 285 ------------------ .../Paypal/src/Core/Support/Response.php | 172 ----------- .../Paypal/src/Core/Support/System.php | 55 ++-- .../src/Exceptions/HttpRequestException.php | 18 +- helpers/HttpHelper.php | 13 + 7 files changed, 58 insertions(+), 621 deletions(-) delete mode 100644 ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php delete mode 100644 ecommerce/PaymentGateways/Paypal/src/Contracts/Support/ResponseContract.php delete mode 100644 ecommerce/PaymentGateways/Paypal/src/Core/Support/Request.php delete mode 100644 ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php diff --git a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php b/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php deleted file mode 100644 index 6fbf42fc15..0000000000 --- a/ecommerce/PaymentGateways/Paypal/src/Contracts/Support/RequestContract.php +++ /dev/null @@ -1,49 +0,0 @@ -method = strtoupper( $method ); - $this->url = $url; - $this->options = $options; - } - - /** - * Create a request from the { method, url, options } object used by the gateways. - * - * @since 4.1.2 - * - * @param object $request_data The request data object. - * - * @return static - */ - public static function from_object( object $request_data ) { - return new static( $request_data->method, $request_data->url, (array) ( $request_data->options ?? array() ) ); - } - - /** - * Send a GET request. - * - * @since 4.1.2 - * - * @param string $url The request URL. - * @param array $options The request options. - * - * @return ResponseContract - * @throws HttpRequestException - */ - public static function get( string $url, array $options = array() ): ResponseContract { - return ( new static( 'GET', $url, $options ) )->send(); - } - - /** - * Send a POST request. - * - * @since 4.1.2 - * - * @param string $url The request URL. - * @param array $options The request options. - * - * @return ResponseContract - * @throws HttpRequestException - */ - public static function post( string $url, array $options = array() ): ResponseContract { - return ( new static( 'POST', $url, $options ) )->send(); - } - - /** - * Send a PUT request. - * - * @since 4.1.2 - * - * @param string $url The request URL. - * @param array $options The request options. - * - * @return ResponseContract - * @throws HttpRequestException - */ - public static function put( string $url, array $options = array() ): ResponseContract { - return ( new static( 'PUT', $url, $options ) )->send(); - } - - /** - * Send a PATCH request. - * - * @since 4.1.2 - * - * @param string $url The request URL. - * @param array $options The request options. - * - * @return ResponseContract - * @throws HttpRequestException - */ - public static function patch( string $url, array $options = array() ): ResponseContract { - return ( new static( 'PATCH', $url, $options ) )->send(); - } - - /** - * Send the request and decode the JSON response. - * - * @since 4.1.2 - * - * @param object $request_data The { method, url, options } request data object. - * @param bool $raw Return the response object instead of the decoded body. - * - * @return mixed|ResponseContract - * @throws HttpRequestException - */ - public static function send_http_request( object $request_data, bool $raw = false ) { - $response = static::from_object( $request_data )->send(); - - return $raw ? $response : $response->json(); - } - - /** - * Send the request. - * - * @since 4.1.2 - * - * @return ResponseContract - * @throws HttpRequestException On a transport error, or on a 4xx/5xx status unless http_errors is false. - */ - public function send(): ResponseContract { - $raw = wp_remote_request( $this->build_url(), $this->build_args() ); - - if ( is_wp_error( $raw ) ) { - throw new HttpRequestException( esc_html( $raw->get_error_message() ) ); - } - - $response = new Response( $raw ); - $status = $response->get_status_code(); - - if ( $status >= 400 && ( $this->options['http_errors'] ?? true ) ) { - throw new HttpRequestException( - esc_html( sprintf( 'HTTP %d %s returned from %s', $status, $response->get_reason_phrase(), $this->url ) ), - $status, - $response - ); - } - - return $response; - } - - /** - * Get the HTTP method. - * - * @since 4.1.2 - * - * @return string - */ - public function get_method(): string { - return $this->method; - } - - /** - * Get the request URL, without the query option applied. - * - * @since 4.1.2 - * - * @return string - */ - public function get_url(): string { - return $this->url; - } - - /** - * Get the request options. - * - * @since 4.1.2 - * - * @return array - */ - public function get_options(): array { - return $this->options; - } - - /** - * Build the final URL with the query option applied. - * - * @since 4.1.2 - * - * @return string - */ - protected function build_url(): string { - if ( empty( $this->options['query'] ) ) { - return $this->url; - } - - return add_query_arg( urlencode_deep( $this->options['query'] ), $this->url ); - } - - /** - * Convert the Guzzle-style options to wp_remote_request() arguments. - * - * @since 4.1.2 - * - * @return array - */ - protected function build_args(): array { - $options = $this->options; - $headers = $options['headers'] ?? array(); - $body = $options['body'] ?? null; - - if ( array_key_exists( 'json', $options ) ) { - $body = wp_json_encode( $options['json'] ); - - if ( ! $this->has_header( $headers, 'Content-Type' ) ) { - $headers['Content-Type'] = 'application/json'; - } - } elseif ( isset( $options['form_params'] ) ) { - // WordPress encodes array bodies as application/x-www-form-urlencoded. - $body = $options['form_params']; - } - - if ( ! empty( $options['auth'] ) ) { - $headers['Authorization'] = is_array( $options['auth'] ) - ? 'Basic ' . base64_encode( $options['auth'][0] . ':' . ( $options['auth'][1] ?? '' ) ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - : $options['auth']; - } - - return array( - 'method' => $this->method, - 'headers' => $headers, - 'body' => $body, - 'timeout' => $options['timeout'] ?? 30, - ); - } - - /** - * Determine if a header is set, ignoring case. - * - * @since 4.1.2 - * - * @param array $headers The headers. - * @param string $name The header name. - * - * @return bool - */ - protected function has_header( array $headers, string $name ): bool { - return in_array( strtolower( $name ), array_map( 'strtolower', array_keys( $headers ) ), true ); - } -} diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php deleted file mode 100644 index c1a7301e2c..0000000000 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/Response.php +++ /dev/null @@ -1,172 +0,0 @@ -raw = $raw; - } - - /** - * Get the HTTP status code. - * - * @since 4.1.2 - * - * @return int - */ - public function get_status_code(): int { - return (int) wp_remote_retrieve_response_code( $this->raw ); - } - - /** - * Get the HTTP reason phrase, e.g. "Not Found". - * - * @since 4.1.2 - * - * @return string - */ - public function get_reason_phrase(): string { - return (string) wp_remote_retrieve_response_message( $this->raw ); - } - - /** - * Determine if the status code is in the 2xx range. - * - * @since 4.1.2 - * - * @return bool - */ - public function is_successful(): bool { - $status = $this->get_status_code(); - - return $status >= 200 && $status < 300; - } - - /** - * Get all response headers as an associative array with lowercase keys. - * - * @since 4.1.2 - * - * @return array - */ - public function get_headers(): array { - $headers = wp_remote_retrieve_headers( $this->raw ); - - if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) { - return $headers->getAll(); - } - - return is_array( $headers ) ? $headers : array(); - } - - /** - * Get a single response header (case-insensitive). - * - * @since 4.1.2 - * - * @param string $name The header name. - * @param mixed $default The value to return when the header is missing. - * - * @return string|array|mixed - */ - public function get_header( string $name, $default = null ) { - $value = wp_remote_retrieve_header( $this->raw, $name ); - - return '' === $value ? $default : $value; - } - - /** - * Get the response body with any UTF-8 BOM stripped. - * - * @since 4.1.2 - * - * @return string - */ - public function get_body(): string { - if ( is_null( $this->body ) ) { - $body = (string) wp_remote_retrieve_body( $this->raw ); - - if ( 0 === strncmp( $body, "\xEF\xBB\xBF", 3 ) ) { - $body = substr( $body, 3 ); - } - - $this->body = $body; - } - - return $this->body; - } - - /** - * Decode the JSON response body. - * - * @since 4.1.2 - * - * @param bool $assoc Return associative arrays instead of objects. - * - * @return mixed Null if the body is empty or not valid JSON. - */ - public function json( bool $assoc = false ) { - $body = $this->get_body(); - - if ( '' === $body ) { - return null; - } - - return json_decode( $body, $assoc ); - } - - /** - * Get the raw response array returned by wp_remote_request(). - * - * @since 4.1.2 - * - * @return array - */ - public function get_raw(): array { - return $this->raw; - } - - /** - * Get the response body as a string. - * - * @since 4.1.2 - * - * @return string - */ - public function __toString() { - return $this->get_body(); - } -} diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index 08dd093506..f39df53148 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -4,8 +4,10 @@ use stdClass; use Brick\Money\Money; use Brick\Math\RoundingMode; +use Tutor\Helpers\HttpHelper; use Ollyo\PaymentHub\Exceptions\NotFoundException; use Ollyo\PaymentHub\Exceptions\InvalidDataException; +use Ollyo\PaymentHub\Exceptions\HttpRequestException; class System { @@ -137,9 +139,8 @@ public static function extractNameParts( $name ): array { /** * Splits the address into two parts if it exceeds a certain length. * - * @param string $address1 The primary address. - * @param string $address2 The secondary address (optional). - * @param int $length The maximum length for the first part of the street address. + * @param object $data Address data. + * @param int $maxLength The maximum length for the first part of the street address. * @return array The formatted part of the street address. * @since 1.0.0 */ @@ -162,9 +163,10 @@ public static function splitAddress( $data, $maxLength ) { * * @return int|null Returns the minor currency amount as an integer, or null if the amount is invalid. * @since 1.0.0 + * @since 4.1.2 Simplified the null check; behavior is unchanged. */ public static function getMinorAmountBasedOnCurrency( $amount, $currency ) { - if ( ! is_null( $amount ) || ! empty( $amount ) ) { + if ( ! empty( $amount ) ) { return Money::of( (float) $amount, $currency, null, RoundingMode::HALF_UP )->getMinorAmount()->toInt(); } @@ -179,9 +181,10 @@ public static function getMinorAmountBasedOnCurrency( $amount, $currency ) { * * @return float|null Returns the major currency amount as a float, or null if the amount is invalid. * @since 1.0.0 + * @since 4.1.2 Simplified the null check; behavior is unchanged. */ public static function convertMinorAmountToMajor( $amount, $currency ) { - if ( ! is_null( $amount ) || ! empty( $amount ) ) { + if ( null !== $amount ) { return Money::ofMinor( $amount, $currency, null, RoundingMode::HALF_UP )->getAmount()->toFloat(); } @@ -234,27 +237,41 @@ public static function updateWebhookUrl( $config ): string { * Sends an HTTP request using the specified method and options. * * @since 1.0.0 - * @since 4.1.2 Sends the request through the WordPress HTTP API instead of Guzzle. + * @since 4.1.2 Sends the request through HttpHelper instead of Guzzle. * - * @param object $request_data An object containing the request method, URL, and options (e.g., headers, body). + * Supported options: headers, body, form_params, query (GET only), and + * auth as [ user, pass ] for Basic auth or a raw Authorization value. * - * @return object|null The decoded JSON response body if $return is true, otherwise null. + * @param array $request_data An object containing the request method (get or post), URL, and options. + * + * @return object|array|null The decoded JSON response body. + * + * @throws \InvalidArgumentException If the HTTP method is not supported. + * @throws HttpRequestException On a transport error or a 4xx/5xx response. */ public static function sendHttpRequest( $request_data ) { - $method = strtolower( $request_data->method ); - if ( ! is_callable( array( Request::class, $method ) ) ) { + $url = $request_data['url']; + $args = $request_data['options'] ?? array(); - $error_message = sprintf( - /* translators: %s: Method name */ - __( 'Unsupported HTTP method: %1$s', 'tutor' ), - $method - ); - throw new \InvalidArgumentException( $error_message ); //phpcs:ignore + $response = HttpHelper::send( $url, $args ); + + if ( $response->has_error() ) { + throw new HttpRequestException( esc_html( $response->get_error_message() ) ); } - $url = $request_data->url; - $response = Request::$method( $url, $request_data->options ); + $status = (int) $response->get_status_code(); + + if ( $status >= 400 ) { + throw new HttpRequestException( esc_html( sprintf( 'HTTP %d returned from %s', $status, $url ) ), $status, $response ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $body = (string) $response->get_body(); + + // Strip the UTF-8 BOM some gateways (e.g. Authorize.net) prepend to JSON responses. + if ( 0 === strncmp( $body, "\xEF\xBB\xBF", 3 ) ) { + $body = substr( $body, 3 ); + } - return json_decode( $response->get_body() ); + return json_decode( $body ); } } diff --git a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php b/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php index c7b167dc4f..ceb34045ce 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php +++ b/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php @@ -3,7 +3,7 @@ use RuntimeException; use Throwable; -use Ollyo\PaymentHub\Contracts\Support\ResponseContract; +use Tutor\Helpers\HttpHelper; /** * Thrown when an HTTP request fails at the transport level or returns a 4xx/5xx status. @@ -17,7 +17,7 @@ class HttpRequestException extends RuntimeException { * * @since 4.1.2 * - * @var ResponseContract|null + * @var HttpHelper|null */ protected $response; @@ -26,12 +26,12 @@ class HttpRequestException extends RuntimeException { * * @since 4.1.2 * - * @param string $message The exception message. - * @param int $code The HTTP status code, or 0 for transport errors. - * @param ResponseContract|null $response The response, if one was received. - * @param Throwable|null $previous The previous exception. + * @param string $message The exception message. + * @param int $code The HTTP status code, or 0 for transport errors. + * @param HttpHelper|null $response The response, if one was received. + * @param Throwable|null $previous The previous exception. */ - public function __construct( string $message = '', int $code = 0, ?ResponseContract $response = null, ?Throwable $previous = null ) { + public function __construct( string $message = '', int $code = 0, ?HttpHelper $response = null, ?Throwable $previous = null ) { parent::__construct( $message, $code, $previous ); $this->response = $response; @@ -42,9 +42,9 @@ public function __construct( string $message = '', int $code = 0, ?ResponseContr * * @since 4.1.2 * - * @return ResponseContract|null + * @return HttpHelper|null */ - public function get_response(): ?ResponseContract { + public function get_response(): ?HttpHelper { return $this->response; } diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index 0bde0aa606..6b95c3c1b7 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -195,4 +195,17 @@ public function has_error() { public function get_error_message() { return $this->wp_error->get_error_message(); } + + public static function send( $url, $args = array() ) { + + $response = wp_remote_request( + $url, + $args + ); + + $self = new self(); + $self->parse_response( $response ); + + return $self; + } } From 7fd4f3b762fa2ed2a11173037d9f48dcc76a0e10 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Fri, 2 Oct 2026 17:22:24 +0600 Subject: [PATCH 043/218] fix(quiz): Verify answers server-side in reveal mode to prevent answer leakage - Remove pre-shipped encoded correct answer IDs and explanations from HTML templates and window context - Add secure `wp_ajax_tutor_quiz_check_answer` endpoint to verify answers per-question on demand - Integrate verification into modern Learning Area and decouple layout, submission, and explanation modules - Prevent in-flight double-click race conditions with verification state guards on navigation and submit buttons - Fix `quiz_answer_display_time` resolution from `window.TutorCore.config` - Lock question inputs upon reveal in legacy mode and re-enable before submission to preserve attempt grading --- assets/core/ts/utils/endpoints.ts | 1 + assets/src/js/front/course/_spotlight-quiz.js | 258 ++++++++++-------- .../frontend/learning-area/quiz/constants.ts | 2 - .../js/frontend/learning-area/quiz/helpers.ts | 45 +-- .../js/frontend/learning-area/quiz/layout.ts | 147 ++++++---- .../frontend/learning-area/quiz/submission.ts | 120 ++------ classes/Quiz.php | 99 +++++++ templates/learning-area/quiz/attempt.php | 60 ++-- templates/single/quiz/body.php | 5 - templates/single/quiz/parts/choice-box.php | 3 +- .../single/quiz/parts/fill-in-the-blank.php | 3 +- templates/single/quiz/parts/ordering.php | 3 +- templates/single/quiz/parts/question.php | 1 + 13 files changed, 398 insertions(+), 349 deletions(-) diff --git a/assets/core/ts/utils/endpoints.ts b/assets/core/ts/utils/endpoints.ts index 43e7372525..b60420d367 100644 --- a/assets/core/ts/utils/endpoints.ts +++ b/assets/core/ts/utils/endpoints.ts @@ -65,6 +65,7 @@ const endpoints = { QUIZ_ABANDON: 'tutor_quiz_abandon', QUIZ_TIMEOUT: 'tutor_quiz_timeout', QUIZ_ATTEMPT_SUBMIT: 'tutor_answering_quiz_question', + QUIZ_CHECK_ANSWER: 'tutor_quiz_check_answer', REVIEW_QUIZ_ANSWERS: 'tutor_review_quiz_answers', INSTRUCTOR_FEEDBACK: 'tutor_instructor_feedback', SAVE_QUESTION_FEEDBACK: 'tutor_save_question_feedback', diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index e7b7ee7bc7..2a0d8494ad 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -46,44 +46,23 @@ window.jQuery(document).ready($ => { ` } - function feedback_response($question_wrap) { - var goNext = false; - - // Prepare answer array - var quiz_answers = JSON.parse(window.tutor_quiz_context.split('').reverse().join('')); - !Array.isArray(quiz_answers) ? quiz_answers = [] : 0; - + function feedback_response($question_wrap, correct_answers, explanation) { if (get_quiz_layout_view() !== 'question_below_each_other') { $('.tutor-quiz-answer-single-info').remove(); } - $('.tutor-quiz-answer-single').removeClass('tutor-quiz-answer-single-correct tutor-quiz-answer-single-incorrect'); + $question_wrap.find('.tutor-quiz-answer-single').removeClass('tutor-quiz-answer-single-correct tutor-quiz-answer-single-incorrect'); - var validatedTrue = true; var $inputs = $question_wrap.find('input'); - var $checkedInputs = $question_wrap.find('input[type="radio"]:checked, input[type="checkbox"]:checked'); - - if (is_reveal_mode()) { - - // Loop through every single checked radio/checkbox input field - $checkedInputs.each(function () { - var $input = $(this); - var isTrue = quiz_answers.indexOf($input.val()) > -1; // $input.attr('data-is-correct') == '1'; - - // And check if the answer is correct - if (!isTrue) { - validatedTrue = false; - } - }); + var correct_ids = Array.isArray(correct_answers) ? correct_answers.map(function (id) { return String(id); }) : []; - // Loop through all the inputs regardless of correct/incorrect + if (correct_ids.length) { $inputs.each(function () { var $input = $(this); var $type = $input.attr('type'); - // Reveal mode feature is currently available for only radio and checkbox type answers if ($type === 'radio' || $type === 'checkbox') { - var isTrue = quiz_answers.indexOf($input.val()) > -1; // $input.attr('data-is-correct') == '1'; + var isTrue = correct_ids.indexOf(String($input.val())) > -1; var checked = $input.is(':checked'); if (isTrue) { @@ -94,28 +73,25 @@ window.jQuery(document).ready($ => { .find('.tutor-quiz-answer-single-info:eq(1)') .remove(); } else { - if ($input.prop("checked")) { + if (checked) { $input.closest('.tutor-quiz-answer-single').addClass('tutor-quiz-answer-single-incorrect'); } } - - if (isTrue && !checked) { - $input.attr('disabled', 'disabled'); - validatedTrue = false; - goNext = true; - } - - // Display answer explanation if available - $question_wrap.find('.tutor-quiz-explanation-wrapper').removeClass('tutor-d-none'); } }); } - if (validatedTrue) { - goNext = true; + $question_wrap.attr('data-revealed', '1'); + $question_wrap.find('.quiz-question-ans-choice-area').css('pointer-events', 'none'); + $inputs.prop('disabled', true); + + if (explanation) { + var $exp = $question_wrap.find('.tutor-quiz-explanation-wrapper'); + $exp.find('.tutor-quiz-explanation-content').html(explanation); + $exp.removeClass('tutor-d-none'); } - return goNext; + return true; } /** @@ -235,7 +211,11 @@ window.jQuery(document).ready($ => { let current_question = parseInt($(this).closest('[data-question_index]').data('question_index')); // Show previous quiz if press previous button if ($(this).hasClass('tutor-quiz-answer-previous-btn')) { - $(this).closest('.quiz-attempt-single-question').hide().prev().show(); + var $prev = $(this).closest('.quiz-attempt-single-question').hide().prev(); + $prev.show(); + if ($prev.attr('data-revealed') === '1') { + $prev.find('.tutor-quiz-next-btn-all').prop('disabled', false); + } counter_el.text(current_question - 1); return; } @@ -257,61 +237,73 @@ window.jQuery(document).ready($ => { return; } - var feedBackNext = feedback_response($question_wrap); - /** - * If not reveal mode then check feedback response - * - * Since validation already checked above, now user's ans is correct - * or not they should move forward. In reveal mode if feedback response is false - * then it was freezing the process. - * - * @since v2.0.9 - */ - if (!is_reveal_mode()) { - if (!feedBackNext) { - return; - } - } - - - if (next_question_id) { - var $nextQuestion = $(next_question_id); - if ($nextQuestion && $nextQuestion.length) { - /** - * check if reveal mode wait for 500ms then - * hide question so that correct answer reveal - * @since 1.8.10 - */ - - if ( - is_reveal_mode() && - get_quiz_layout_view() === 'single_question' && - revealModeSupportedQuestions.includes($question_wrap.data('question-type')) - ) { - setTimeout(() => { - $('.quiz-attempt-single-question').hide(); - $nextQuestion.show(); - }, get_reveal_wait_time()); - } else { + var moveToNext = function () { + if (next_question_id) { + var $nextQuestion = $(next_question_id); + if ($nextQuestion && $nextQuestion.length) { $('.quiz-attempt-single-question').hide(); $nextQuestion.show(); - } - + if (has_pagination_enabled() && $('.tutor-quiz-questions-pagination').length) { + $('.tutor-quiz-question-paginate-item').removeClass('active'); + $('.tutor-quiz-questions-pagination a[href="' + next_question_id + '"]').addClass('active'); + } - /** - * If pagination exists, set active class - */ + if ($nextQuestion.attr('data-revealed') === '1') { + $nextQuestion.find('.tutor-quiz-next-btn-all').prop('disabled', false); + } - if (has_pagination_enabled() && $('.tutor-quiz-questions-pagination').length) { - $('.tutor-quiz-question-paginate-item').removeClass('active'); - $('.tutor-quiz-questions-pagination a[href="' + next_question_id + '"]').addClass('active'); + counter_el.text(current_question + 1); } - - // Increase counter - counter_el.text(current_question + 1); } + }; + + if ( + is_reveal_mode() && + get_quiz_layout_view() === 'single_question' && + revealModeSupportedQuestions.includes($question_wrap.data('question-type')) + ) { + if ($question_wrap.attr('data-revealed') === '1') { + moveToNext(); + return; + } + var $form = $('form#tutor-answering-quiz'); + var attemptId = $form.find('input[name="attempt_id"]').val(); + var quizId = $form.find('input[name="quiz_id"]').val(); + var checkedAnswers = []; + $question_wrap.find('input[type="radio"]:checked, input[type="checkbox"]:checked').each(function () { + checkedAnswers.push($(this).val()); + }); + + $that.prop('disabled', true); + + $.ajax({ + url: window._tutorobject.ajaxurl, + type: 'POST', + data: { + action: 'tutor_quiz_check_answer', + _tutor_nonce: window._tutorobject._tutor_nonce, + attempt_id: attemptId, + quiz_id: quizId, + question_id: question_id, + answers: checkedAnswers, + }, + success: function (res) { + if (res && res.success && res.data) { + feedback_response($question_wrap, res.data.correct_answer_ids, res.data.answer_explanation); + } + }, + complete: function () { + setTimeout(function () { + $that.prop('disabled', false); + moveToNext(); + }, get_reveal_wait_time()); + }, + }); + return; } + + moveToNext(); }); $(document).on('click', '.tutor-quiz-question-paginate-item', function (e) { @@ -320,6 +312,9 @@ window.jQuery(document).ready($ => { var $question = $($that.attr('href')); $('.quiz-attempt-single-question').hide(); $question.show(); + if ($question.attr('data-revealed') === '1') { + $question.find('.tutor-quiz-next-btn-all').prop('disabled', false); + } //Active Class $('.tutor-quiz-question-paginate-item').removeClass('active'); @@ -367,7 +362,6 @@ window.jQuery(document).ready($ => { if ($questions_wrap.length) { $questions_wrap.each(function (index, question) { quiz_validated = tutor_quiz_validation($(question), quiz_validated); - feedback_validated = feedback_response($(question)); if (revealModeSupportedQuestions.includes($(question).data('question-type'))) { hasAnyRevealModeQuestion = true; @@ -377,16 +371,18 @@ window.jQuery(document).ready($ => { //If auto submit option is enabled after time expire submit current progress if (_tutorobject.quiz_options.quiz_when_time_expires === 'auto_submit' && $('#tutor-quiz-time-update').hasClass('tutor-quiz-time-expired')) { quiz_validated = true; - feedback_validated = true; } - if (quiz_validated && feedback_validated) { + if (quiz_validated) { let wait = 500 if (is_reveal_mode() && get_quiz_layout_view() === 'question_below_each_other' && hasAnyRevealModeQuestion) { wait = get_reveal_wait_time() submitted_form.find(':submit').addClass('is-loading').attr('disabled', 'disabled') } - setTimeout(() => { e.target.submit() }, wait); + setTimeout(() => { + $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); + e.target.submit(); + }, wait); } else { if (quizSubmitBtn) { quizSubmitBtn.classList.remove('is-loading') @@ -397,31 +393,74 @@ window.jQuery(document).ready($ => { $(".tutor-quiz-submit-btn").click(function (event) { event.preventDefault(); + var $btn = $(this); + var $form = $("#tutor-answering-quiz"); var $questions_wrap = $('.quiz-attempt-single-question'); - const lastQuestion = $questions_wrap[$questions_wrap.length - 1]; - const lastQuestionType = $(lastQuestion).data('question-type'); - - if (is_reveal_mode() && revealModeSupportedQuestions.includes(lastQuestionType)) { - var validated = true; - if ($questions_wrap.length) { - $questions_wrap.each(function (index, question) { - validated = tutor_quiz_validation($(question)); - validated = feedback_response($(question)); + var validated = true; + $questions_wrap.each(function (index, question) { + validated = tutor_quiz_validation($(question), validated); + }); + if (!validated) { + return; + } - }); + const lastQuestion = $questions_wrap[$questions_wrap.length - 1]; + const $lastQuestion = $(lastQuestion); + const lastQuestionType = $lastQuestion.data('question-type'); + + if ( + is_reveal_mode() && + get_quiz_layout_view() === 'single_question' && + revealModeSupportedQuestions.includes(lastQuestionType) + ) { + if ($lastQuestion.attr('data-revealed') === '1') { + $btn.prop('disabled', true).addClass('is-loading'); + $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); + document.getElementById('tutor-answering-quiz').submit(); + return; } - $(this).attr('disabled', 'disabled') - setTimeout(() => { - $(this).addClass('is-loading'); - $("#tutor-answering-quiz").submit(); - }, get_reveal_wait_time()); - } else { - $(this).attr('disabled', 'disabled').addClass('is-loading'); - $("#tutor-answering-quiz").submit(); + var attemptId = $form.find('input[name="attempt_id"]').val(); + var quizId = $form.find('input[name="quiz_id"]').val(); + var question_id = parseInt($lastQuestion.attr('id').match(/\d+/)[0], 10); + var checkedAnswers = []; + $lastQuestion.find('input[type="radio"]:checked, input[type="checkbox"]:checked').each(function () { + checkedAnswers.push($(this).val()); + }); + + $btn.prop('disabled', true); + + $.ajax({ + url: window._tutorobject.ajaxurl, + type: 'POST', + data: { + action: 'tutor_quiz_check_answer', + _tutor_nonce: window._tutorobject._tutor_nonce, + attempt_id: attemptId, + quiz_id: quizId, + question_id: question_id, + answers: checkedAnswers, + }, + success: function (res) { + if (res && res.success && res.data) { + feedback_response($lastQuestion, res.data.correct_answer_ids, res.data.answer_explanation); + } + }, + complete: function () { + setTimeout(function () { + $btn.addClass('is-loading'); + $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); + document.getElementById('tutor-answering-quiz').submit(); + }, get_reveal_wait_time()); + }, + }); + return; } + $btn.prop('disabled', true).addClass('is-loading'); + $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); + document.getElementById('tutor-answering-quiz').submit(); }); //warn user before leave page if quiz is running @@ -448,6 +487,7 @@ window.jQuery(document).ready($ => { id: 'leave', class: 'tutor-btn tutor-btn-outline-primary', callback: function () { + $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); var formData = $('form#tutor-answering-quiz').serialize() + '&action=' + 'tutor_quiz_abandon'; $.ajax({ url: window._tutorobject.ajaxurl, diff --git a/assets/src/js/frontend/learning-area/quiz/constants.ts b/assets/src/js/frontend/learning-area/quiz/constants.ts index cc4b77ba7d..d99d8e8e68 100644 --- a/assets/src/js/frontend/learning-area/quiz/constants.ts +++ b/assets/src/js/frontend/learning-area/quiz/constants.ts @@ -3,7 +3,6 @@ import { __ } from '@wordpress/i18n'; export type RevealQuestionType = (typeof QUIZ_REVEAL_CONFIG.SUPPORTED_TYPES)[number]; export const QUIZ_REVEAL_CONFIG = { - ANSWER_CONTEXT_ID: 'tutor-quiz-context', DEFAULT_WAIT_MS: 2000, SUPPORTED_TYPES: ['true_false', 'single_choice', 'multiple_choice'] as const, OPTION_SELECTOR: '.tutor-quiz-question-option', @@ -11,7 +10,6 @@ export const QUIZ_REVEAL_CONFIG = { EXPLANATION_SELECTOR: '[data-quiz-explanation]', EXPLANATION_TRIGGER_SELECTOR: '[data-quiz-explanation-toggle]', EXPLANATION_BODY_SELECTOR: '.tutor-quiz-explanation-body', - EXPLANATION_CONTENT_DATASET: 'quizExplanationContent', DATA_OPTION_ATTR: 'data-option', DATA_REVEALED_ATTR: 'data-revealed', DATA_RESULT_ATTR: 'data-reveal-result', diff --git a/assets/src/js/frontend/learning-area/quiz/helpers.ts b/assets/src/js/frontend/learning-area/quiz/helpers.ts index 5f6667cf15..76b7cb00ea 100644 --- a/assets/src/js/frontend/learning-area/quiz/helpers.ts +++ b/assets/src/js/frontend/learning-area/quiz/helpers.ts @@ -1,33 +1,10 @@ import { QUIZ_LAYOUT_KEYS, QUIZ_REVEAL_CONFIG } from './constants'; -export const decodeHexString = (encoded: string): string | null => { - const bytes = encoded.match(/.{1,2}/g); - if (!bytes) { - return null; - } - return bytes.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''); -}; - -export const decodeExplanationContent = (encoded: string): string => { - if (!encoded) { - return ''; - } - try { - const hexDecoded = decodeHexString(encoded); - if (hexDecoded) { - return decodeURIComponent(hexDecoded); - } - } catch { - // fall through to legacy decode - } - try { - return decodeURIComponent(encoded.split('').reverse().join('')); - } catch { - return ''; - } -}; - -export const revealQuestionWithAnswers = (wrapper: HTMLElement, revealAnswerIds: number[]): void => { +export const revealQuestionWithAnswers = ( + wrapper: HTMLElement, + revealAnswerIds: number[], + explanationHtml?: string, +): void => { const question = wrapper.querySelector(QUIZ_REVEAL_CONFIG.QUESTION_SELECTOR) as HTMLElement | null; if (!question) { return; @@ -39,13 +16,11 @@ export const revealQuestionWithAnswers = (wrapper: HTMLElement, revealAnswerIds: const explanationTrigger = wrapper.querySelector(QUIZ_REVEAL_CONFIG.EXPLANATION_TRIGGER_SELECTOR); const explanation = wrapper.querySelector(QUIZ_REVEAL_CONFIG.EXPLANATION_SELECTOR); const explanationBody = explanation?.querySelector(QUIZ_REVEAL_CONFIG.EXPLANATION_BODY_SELECTOR) ?? null; - const encodedExplanation = explanation?.dataset?.[QUIZ_REVEAL_CONFIG.EXPLANATION_CONTENT_DATASET] ?? ''; - if (explanationBody && encodedExplanation && !explanationBody.innerHTML.trim()) { - const decoded = decodeExplanationContent(encodedExplanation); - if (decoded) { - explanationBody.innerHTML = decoded; - } + + if (explanationBody && explanationHtml) { + explanationBody.innerHTML = explanationHtml; } + if (explanationTrigger && explanationTrigger.getAttribute('aria-expanded') !== 'true') { explanationTrigger.click(); } @@ -139,7 +114,7 @@ export const getAttemptedQuestionCountFromForm = (formId: string): number => { }; /** - * Attaches mouse drag-to-scroll behaviour to a horizontally scrollable element. + * Attaches mouse drag-to-scroll behavior to a horizontally scrollable element. * Skips drag when the element has no overflow. Suppresses click events on child * elements when the pointer moved enough to count as a drag (prevents accidental * navigation after a drag gesture). diff --git a/assets/src/js/frontend/learning-area/quiz/layout.ts b/assets/src/js/frontend/learning-area/quiz/layout.ts index 4c95085e98..bfe8fa537c 100644 --- a/assets/src/js/frontend/learning-area/quiz/layout.ts +++ b/assets/src/js/frontend/learning-area/quiz/layout.ts @@ -1,4 +1,5 @@ import type { AlpineComponentMeta } from '@Core/ts/types'; +import endpoints from '@Core/ts/utils/endpoints'; import { QUIZ_LAYOUT_KEYS, QUIZ_LAYOUT_SELECTORS, QUIZ_REVEAL_CONFIG, QuizLayoutType } from './constants'; import { createDragScroll, revealQuestionWithAnswers } from './helpers'; @@ -7,12 +8,15 @@ export interface QuizLayoutConfig { layout: (typeof QuizLayoutType)[keyof typeof QuizLayoutType]; formId: string; totalQuestions: number; + attemptId?: string; + quizId?: number; enableAnswerReveal?: boolean; revealWaitMs?: number; } const quizLayout = (config: QuizLayoutConfig) => { - const { form, tutorConfig } = window.TutorCore; + const { form } = window.TutorCore; + const { tutorConfig } = window.TutorCore.config; let container: Element | null | undefined = null; let handleFirstTab: ((e: KeyboardEvent) => void) | null = null; @@ -28,14 +32,16 @@ const quizLayout = (config: QuizLayoutConfig) => { totalQuestions: Number(config.totalQuestions) || 0, currentIndex: 1, + attemptId: config.attemptId ?? '', + quizId: config.quizId ?? 0, enableAnswerReveal: config.enableAnswerReveal ?? false, revealWaitMs: config.revealWaitMs ?? null, - revealAnswerIds: [] as number[], answerRequiredByIndex: {} as Record, revealStateByIndex: {} as Record, skippedByIndex: {} as Record, revealFooterState: '' as '' | 'correct' | 'incorrect', isRevealing: false, + isVerifying: false, revealTimeoutId: null as number | null, $el: null as HTMLElement | null, $root: null as HTMLElement | null, @@ -66,7 +72,6 @@ const quizLayout = (config: QuizLayoutConfig) => { if (handleFirstTab) window.addEventListener('keydown', handleFirstTab); - this.revealAnswerIds = this.getRevealAnswerIds(); this.answerRequiredByIndex = this.getAnswerRequiredMap(); this.revealStateByIndex = this.getRevealStateMap(); this.skippedByIndex = this.getSkippedStateMap(); @@ -260,6 +265,9 @@ const quizLayout = (config: QuizLayoutConfig) => { if (this.layout === QuizLayoutType.QUESTION_BELOW_EACH_OTHER) { return; } + if (this.isVerifying) { + return; + } if (this.currentIndex > 1) { this.clearRevealTimeout(); this.markCurrentAsSkipped(); @@ -276,6 +284,10 @@ const quizLayout = (config: QuizLayoutConfig) => { return; } + if (this.isVerifying) { + return; + } + if (!skipValidation && this.shouldDisableNextButton()) { return; } @@ -304,15 +316,18 @@ const quizLayout = (config: QuizLayoutConfig) => { } this.isRevealing = true; - this.revealQuestion(wrapper); - this.syncRevealFooterState(wrapper); - const wait = this.getRevealWaitTime(); - this.revealTimeoutId = window.setTimeout(() => { - this.isRevealing = false; - this.revealTimeoutId = null; - this.moveToNextQuestion(); - }, wait); - return; + const verified = await this.verifyAndRevealQuestion(wrapper, this.currentIndex); + if (verified) { + const wait = this.getRevealWaitTime(); + this.revealTimeoutId = window.setTimeout(() => { + this.isRevealing = false; + this.revealTimeoutId = null; + this.moveToNextQuestion(); + }, wait); + return; + } + + this.isRevealing = false; } this.moveToNextQuestion(); @@ -322,6 +337,9 @@ const quizLayout = (config: QuizLayoutConfig) => { if (this.layout === QuizLayoutType.QUESTION_BELOW_EACH_OTHER) { return; } + if (this.isVerifying) { + return; + } if (!index || index < 1 || index > this.totalQuestions) { return; } @@ -366,7 +384,7 @@ const quizLayout = (config: QuizLayoutConfig) => { if (!Number.isNaN(feedbackWaitMs) && feedbackWaitMs > 0) { return feedbackWaitMs; } - const configValue = Number(tutorConfig.quiz_answer_display_time ?? ''); + const configValue = Number(tutorConfig?.quiz_answer_display_time ?? ''); if (!Number.isNaN(configValue) && configValue > 0) { return configValue; } @@ -377,31 +395,6 @@ const quizLayout = (config: QuizLayoutConfig) => { return this.enableAnswerReveal; }, - getRevealAnswerIds(): number[] { - const script = document.getElementById(QUIZ_REVEAL_CONFIG.ANSWER_CONTEXT_ID); - if (!script?.textContent) { - return []; - } - - try { - const encoded = script.textContent.trim(); - const decoded = encoded - .match(/.{1,2}/g) - ?.map((byte) => String.fromCharCode(parseInt(byte, 16))) - .join(''); - if (!decoded) { - return []; - } - const parsed = JSON.parse(decoded); - if (!Array.isArray(parsed)) { - return []; - } - return parsed.map((value) => Number(value)).filter((value) => !Number.isNaN(value)); - } catch { - return []; - } - }, - getQuestionElement(wrapper: HTMLElement): HTMLElement | null { return wrapper.querySelector(QUIZ_REVEAL_CONFIG.QUESTION_SELECTOR); }, @@ -419,42 +412,80 @@ const quizLayout = (config: QuizLayoutConfig) => { if (!this.isRevealMode()) { return false; } - if (!this.revealAnswerIds.length) { - return false; - } const questionType = this.getQuestionType(wrapper); return (QUIZ_REVEAL_CONFIG.SUPPORTED_TYPES as readonly string[]).includes(questionType); }, - revealQuestion(wrapper: HTMLElement) { - revealQuestionWithAnswers(wrapper, this.revealAnswerIds); + async verifyAndRevealQuestion(wrapper: HTMLElement, index: number): Promise { + const question = this.getQuestionElement(wrapper); + if (!question) { + return false; + } + + const questionId = Number(question.id); + if (!questionId) { + return false; + } + + const checkedInputs = Array.from( + question.querySelectorAll('input[type="radio"]:checked, input[type="checkbox"]:checked'), + ); + const answers = checkedInputs.map((input) => Number(input.value)).filter((val) => !Number.isNaN(val)); + + this.isVerifying = true; + + try { + const response = await window.TutorCore.api.wpPost<{ + success: boolean; + data: { + question_id: number; + is_correct: boolean; + correct_answer_ids: number[]; + answer_explanation?: string; + }; + }>(endpoints.QUIZ_CHECK_ANSWER, { + attempt_id: this.attemptId, + quiz_id: this.quizId, + question_id: questionId, + answers, + }); + + if (response?.data) { + const { is_correct, correct_answer_ids, answer_explanation } = response.data; + revealQuestionWithAnswers(wrapper, correct_answer_ids, answer_explanation); + this.revealStateByIndex[index] = is_correct ? 'correct' : 'incorrect'; + this.revealFooterState = is_correct ? 'correct' : 'incorrect'; + return true; + } + } catch { + // Fallback gracefully on network error + } finally { + this.isVerifying = false; + } + + return false; }, - revealOnSubmit(): boolean { + async revealOnSubmit(): Promise { if (!this.isRevealMode()) { return false; } if (this.layout === QuizLayoutType.QUESTION_BELOW_EACH_OTHER) { - const wrappers = Array.from( - (this.$root ?? this.$el)?.querySelectorAll(`${QUIZ_LAYOUT_SELECTORS.QUESTION_WRAPPER}`) ?? [], - ); - let revealedAny = false; - wrappers.forEach((wrapper) => { - if (this.shouldReveal(wrapper)) { - this.revealQuestion(wrapper); - revealedAny = true; - } - }); - return revealedAny; + return false; } const wrapper = this.getQuestionWrapper(this.currentIndex); - if (!wrapper || !this.shouldReveal(wrapper)) { + if (!wrapper || !this.shouldReveal(wrapper) || this.isQuestionRevealed(wrapper)) { return false; } - this.revealQuestion(wrapper); - return true; + + const revealed = await this.verifyAndRevealQuestion(wrapper, this.currentIndex); + if (revealed) { + return this.getRevealWaitTime(); + } + + return false; }, getQuestionWrapper(index: number) { diff --git a/assets/src/js/frontend/learning-area/quiz/submission.ts b/assets/src/js/frontend/learning-area/quiz/submission.ts index 39c70641a8..a6ba78ae23 100644 --- a/assets/src/js/frontend/learning-area/quiz/submission.ts +++ b/assets/src/js/frontend/learning-area/quiz/submission.ts @@ -1,14 +1,8 @@ import { type MutationState } from '@Core/ts/services/Query'; import type { AlpineComponentMeta } from '@Core/ts/types'; -import { - ERROR_MESSAGES, - QuestionTimeoutAction, - QUIZ_ABANDON_CONFIG, - QUIZ_LAYOUT_SELECTORS, - QUIZ_REVEAL_CONFIG, -} from './constants'; -import { getAttemptedQuestionCountFromForm, revealQuestionWithAnswers } from './helpers'; +import { ERROR_MESSAGES, QuestionTimeoutAction, QUIZ_ABANDON_CONFIG } from './constants'; +import { getAttemptedQuestionCountFromForm } from './helpers'; export interface QuizSubmissionConfig { formId: string; @@ -16,17 +10,15 @@ export interface QuizSubmissionConfig { quizId: number; abandonModalId: string; totalQuestions: number; - enableAnswerReveal?: boolean; - revealWaitMs?: number; submittedModalId?: string; timeoutModalId?: string; + beforeSubmit?: () => Promise; } const quizSubmission = (config: QuizSubmissionConfig) => { const { query, toast, form, modal, constants, endpoints } = window.TutorCore; const { convertToErrorMessage } = window.TutorCore.error; const { wpPostForm, wpPost } = window.TutorCore.api; - const { tutorConfig } = window.TutorCore.config; const { TUTOR_CUSTOM_EVENTS } = constants; return { @@ -35,8 +27,6 @@ const quizSubmission = (config: QuizSubmissionConfig) => { quizId: config.quizId, abandonModalId: config.abandonModalId, totalQuestions: Number(config.totalQuestions) || 0, - enableAnswerReveal: config.enableAnswerReveal ?? false, - revealWaitMs: config.revealWaitMs ?? null, submittedModalId: config.submittedModalId ?? '', timeoutModalId: config.timeoutModalId ?? '', @@ -133,102 +123,32 @@ const quizSubmission = (config: QuizSubmissionConfig) => { }); }, - handleQuizSubmit(data: Record) { - if (this.isRevealSubmitting) { + async handleQuizSubmit(data: Record) { + if (this.isRevealSubmitting || this.submitQuizMutation?.isPending) { return; } - if (this.isRevealMode()) { - const revealWait = this.getRevealWaitTime(); - const shouldDelay = this.revealOnSubmit(); - if (shouldDelay) { - this.isRevealSubmitting = true; - const payload = this.buildSubmitPayload(data); - window.setTimeout(() => { - this.submitQuizMutation?.mutate(payload); - this.isRevealSubmitting = false; - }, revealWait); - return; - } - } + this.isRevealSubmitting = true; const payload = this.buildSubmitPayload(data); - this.submitQuizMutation?.mutate(payload); - }, - - getRevealWaitTime(): number { - const feedbackWaitMs = Number(this.revealWaitMs ?? ''); - if (!Number.isNaN(feedbackWaitMs) && feedbackWaitMs > 0) { - return feedbackWaitMs; - } - const configValue = Number(tutorConfig.quiz_answer_display_time ?? ''); - if (!Number.isNaN(configValue) && configValue > 0) { - return configValue; - } - return QUIZ_REVEAL_CONFIG.DEFAULT_WAIT_MS; - }, - isRevealMode(): boolean { - return this.enableAnswerReveal; - }, - - getRevealAnswerIds(): number[] { - const script = document.getElementById(QUIZ_REVEAL_CONFIG.ANSWER_CONTEXT_ID); - if (!script?.textContent) { - return []; - } - - try { - const encoded = script.textContent.trim(); - const decoded = encoded - .match(/.{1,2}/g) - ?.map((byte) => String.fromCharCode(parseInt(byte, 16))) - .join(''); - if (!decoded) { - return []; - } - const parsed = JSON.parse(decoded); - if (!Array.isArray(parsed)) { - return []; + if (config.beforeSubmit) { + try { + const delay = await config.beforeSubmit.call(this); + if (typeof delay === 'number' && delay > 0) { + window.setTimeout(() => { + this.submitQuizMutation?.mutate(payload); + this.isRevealSubmitting = false; + }, delay); + return; + } + } catch { + // Proceed with submission on reveal failure } - return parsed.map((value) => Number(value)).filter((value) => !Number.isNaN(value)); - } catch { - return []; } - }, - revealQuestion(wrapper: HTMLElement, revealAnswerIds: number[]) { - revealQuestionWithAnswers(wrapper, revealAnswerIds); - }, - - revealOnSubmit(): boolean { - const revealAnswerIds = this.getRevealAnswerIds(); - if (!revealAnswerIds.length) { - return false; - } - - const root = this.$root ?? this.$el; - if (!root) { - return false; - } - - const wrappers = Array.from(root.querySelectorAll(QUIZ_LAYOUT_SELECTORS.QUESTION_WRAPPER)); - let revealedAny = false; - - wrappers.forEach((wrapper) => { - const question = wrapper.querySelector(QUIZ_REVEAL_CONFIG.QUESTION_SELECTOR) as HTMLElement | null; - if (!question) { - return; - } - const questionType = question.dataset?.question ?? ''; - if (!(QUIZ_REVEAL_CONFIG.SUPPORTED_TYPES as readonly string[]).includes(questionType)) { - return; - } - this.revealQuestion(wrapper, revealAnswerIds); - revealedAny = true; - }); - - return revealedAny; + this.isRevealSubmitting = false; + this.submitQuizMutation?.mutate(payload); }, handleQuizError() { diff --git a/classes/Quiz.php b/classes/Quiz.php index 8ec7c3c061..dce6bc19bf 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -156,6 +156,13 @@ public function __construct( $register_hooks = true ) { */ add_action( 'wp_ajax_tutor_quiz_abandon', array( $this, 'tutor_quiz_abandon' ) ); + /** + * Quiz check answer action (per-question reveal) + * + * @since 4.2.0 + */ + add_action( 'wp_ajax_tutor_quiz_check_answer', array( $this, 'tutor_quiz_check_answer' ) ); + /** * Delete quiz attempt * @@ -1263,6 +1270,98 @@ public function tutor_quiz_timeout() { wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) ); } + /** + * Check answer for a single quiz question during answer reveal mode. + * + * Grades only the submitted question and returns its verification status, + * correct option IDs, and answer explanation. Correct answer data is never + * sent for any subsequent questions. + * + * @since 4.2.0 + * + * @return void JSON response + */ + public function tutor_quiz_check_answer() { + tutor_utils()->checking_nonce(); + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( 'Please sign in to perform this operation', 'tutor' ) ), 401 ); + } + + $user_id = get_current_user_id(); + $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); + $question_id = Input::post( 'question_id', 0, Input::TYPE_INT ); + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT ); + + $attempt = self::validate_attempt( $attempt_id, $user_id ); + if ( ! $attempt ) { + wp_send_json_error( array( 'message' => __( 'Operation not allowed, attempt not found or permission denied', 'tutor' ) ), 403 ); + } + + if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status || QuizModel::ATTEMPT_ENDED === $attempt->attempt_status ) { + wp_send_json_error( array( 'message' => __( 'Attempt has ended or timed out', 'tutor' ) ), 400 ); + } + + $effective_quiz_id = $quiz_id ? $quiz_id : (int) $attempt->quiz_id; + $quiz_settings = tutor_utils()->get_quiz_option( $effective_quiz_id ); + $enable_answer_reveal = '1' === (string) ( $quiz_settings['enable_answer_reveal'] ?? '0' ); + if ( ! $enable_answer_reveal ) { + wp_send_json_error( array( 'message' => __( 'Answer reveal is disabled for this quiz', 'tutor' ) ), 403 ); + } + + global $wpdb; + $question = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}tutor_quiz_questions WHERE question_id = %d AND quiz_id = %d", + $question_id, + $attempt->quiz_id + ) + ); + + if ( ! $question ) { + wp_send_json_error( array( 'message' => __( 'Question not found or does not belong to this quiz', 'tutor' ) ), 404 ); + } + + $answers = QuizModel::get_answers_by_quiz_question( $question_id ); + $correct_answer_ids = array(); + if ( is_array( $answers ) ) { + foreach ( $answers as $answer ) { + if ( ! empty( $answer->is_correct ) ) { + $correct_answer_ids[] = (int) $answer->answer_id; + } + } + } + + $submitted = Input::post( 'answers', array(), Input::TYPE_ARRAY ); + if ( empty( $submitted ) ) { + $single_value = Input::post( 'answer' ); + if ( null !== $single_value && '' !== $single_value ) { + $submitted = array( $single_value ); + } + } + $submitted_ids = array_map( 'intval', array_filter( (array) $submitted, 'is_numeric' ) ); + + $is_correct = false; + if ( ! empty( $submitted_ids ) && ! empty( $correct_answer_ids ) ) { + $sorted_submitted_ids = $submitted_ids; + $sorted_correct_answer_ids = $correct_answer_ids; + sort( $sorted_submitted_ids ); + sort( $sorted_correct_answer_ids ); + $is_correct = ( $sorted_submitted_ids === $sorted_correct_answer_ids ); + } + + $explanation = apply_filters( 'tutor_quiz_question_answer_explanation', '', $question, $attempt ); + + wp_send_json_success( + array( + 'question_id' => $question_id, + 'is_correct' => $is_correct, + 'correct_answer_ids' => $correct_answer_ids, + 'answer_explanation' => $explanation, + ) + ); + } + /** * Review quiz answer * diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index 5c08d9a27e..8f4b1c1a83 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -69,21 +69,6 @@ $questions = tutor_utils()->get_random_questions_by_quiz(); $hide_question_number_overview = (bool) ( $quiz_settings['hide_question_number_overview'] ?? false ); -$reveal_question_types = array( 'true_false', 'single_choice', 'multiple_choice' ); -$quiz_answers = array(); -foreach ( $questions as $question ) { - if ( ! in_array( $question->question_type, $reveal_question_types, true ) ) { - continue; - } - - $answers = QuizModel::get_answers_by_quiz_question( $question->question_id ); - foreach ( $answers as $answer ) { - if ( ! empty( $answer->is_correct ) ) { - $quiz_answers[] = $answer->answer_id; - } - } -} - $form_id = 'quiz-attempt-form-' . $tutor_is_started_quiz->attempt_id . '-' . $tutor_is_started_quiz->quiz_id; $modal_id = 'tutor-quiz-abandon-modal'; $submitted_modal_id = 'tutor-quiz-submitted-modal'; @@ -124,34 +109,43 @@ class="tutor-quiz tutor-quiz-submission" mode: "onSubmit", defaultValues: , }); - const submission = tutorQuizSubmission({ + + const layout = tutorQuizLayout({ + layout: "", formId: "", attemptId: "attempt_id ); ?>", quizId: quiz_id ); ?>, - abandonModalId: "", - submittedModalId: "", - timeoutModalId: "", totalQuestions: , enableAnswerReveal: , revealWaitMs: , }); - const layout = tutorQuizLayout({ - layout: "", + const submission = tutorQuizSubmission({ formId: "", + attemptId: "attempt_id ); ?>", + quizId: quiz_id ); ?>, + abandonModalId: "", + submittedModalId: "", + timeoutModalId: "", totalQuestions: , - enableAnswerReveal: , - revealWaitMs: , + beforeSubmit() { + return layout.revealOnSubmit.call(this); + }, }); return { ...form, - ...submission, ...layout, + ...submission, init() { form.init?.call(this); - submission.init?.call(this); layout.init?.call(this); + submission.init?.call(this); + }, + destroy() { + form.destroy?.call(this); + layout.destroy?.call(this); + submission.destroy?.call(this); }, }; })()' @@ -238,6 +232,7 @@ class="tutor-quiz-question-paginate-item" data-quiz-question-index="" :class="getPaginationItemClass()" :data-state="getPaginationState()" + :disabled="isVerifying" @click="goTo()" > @@ -299,7 +294,7 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->variant( Variant::LINK_GRAY ) ->attr( 'type', 'button' ) - ->attr( ':disabled', 'isRevealSubmitting || isRevealing' ) + ->attr( ':disabled', 'isRevealSubmitting || isRevealing || isVerifying' ) ->attr( 'x-show', 'canSkip(currentIndex) && revealFooterState === ""' ) ->attr( '@click', 'goNext({ skipValidation: true })' ) ->attr( 'class', 'tutor-quiz-skip-btn' ) @@ -315,7 +310,7 @@ class="tutor-quiz-footer-feedback-text" ->icon( Icon::ARROW_LEFT_2, 'left', 20 ) ->flip_rtl() ->attr( 'type', 'button' ) - ->attr( ':disabled', 'isRevealSubmitting' ) + ->attr( ':disabled', 'isRevealSubmitting || isVerifying' ) ->attr( '@click', 'goPrev()' ) ->attr( 'x-show', $show_previous_button ? 'currentIndex > 1' : 'false' ) ->attr( 'class', 'tutor-quiz-answer-previous-btn' ) @@ -325,7 +320,8 @@ class="tutor-quiz-footer-feedback-text" ->label( __( 'Next', 'tutor' ) ) ->size( Size::LARGE ) ->attr( 'type', 'button' ) - ->attr( ':disabled', 'isRevealSubmitting || shouldDisableNextButton()' ) + ->attr( ':disabled', 'isRevealSubmitting || isVerifying || shouldDisableNextButton()' ) + ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying }' ) ->attr( '@click', 'goNext()' ) ->attr( 'x-show', 'currentIndex < totalQuestions' ) ->attr( 'class', 'tutor-quiz-answer-next-btn' ) @@ -336,7 +332,7 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->attr( 'type', 'submit' ) ->attr( 'x-show', 'currentIndex === totalQuestions' ) - ->attr( ':disabled', 'isRevealSubmitting' ) + ->attr( ':disabled', 'isRevealSubmitting || submitQuizMutation?.isPending' ) ->attr( ':class', '{ \'tutor-btn-loading\': submitQuizMutation?.isPending }' ) ->attr( 'class', 'tutor-quiz-submit-btn' ) ->render(); @@ -352,7 +348,7 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->attr( 'form', $form_id ) ->attr( 'type', 'submit' ) - ->attr( ':disabled', 'isRevealSubmitting' ) + ->attr( ':disabled', 'isRevealSubmitting || submitQuizMutation?.isPending' ) ->attr( ':class', '{ \'tutor-btn-loading\': submitQuizMutation?.isPending }' ) ->attr( 'style', 'display: block; margin: 0 auto; min-width: 290px;' ) ->render(); @@ -408,7 +404,3 @@ class="tutor-quiz-footer-feedback-text" ->render(); ?> - - diff --git a/templates/single/quiz/body.php b/templates/single/quiz/body.php index baa32f7a2b..1eaff9cb1e 100644 --- a/templates/single/quiz/body.php +++ b/templates/single/quiz/body.php @@ -33,7 +33,6 @@ $can_retry_quiz = Quiz::can_retry_quiz( $limit_attempts_allowed, $configured_attempts_allowed, $attempted_count ); $attempt_remaining = (int) $attempts_allowed - (int) $attempted_count; -$quiz_answers = array(); if ( 0 !== $attempted_count ) { ?> @@ -99,7 +98,3 @@ - - diff --git a/templates/single/quiz/parts/choice-box.php b/templates/single/quiz/parts/choice-box.php index ccafcecbd4..c2cb27b04f 100644 --- a/templates/single/quiz/parts/choice-box.php +++ b/templates/single/quiz/parts/choice-box.php @@ -65,8 +65,7 @@ answer_title ); - $answer->is_correct ? $quiz_answers[] = $answer->answer_id : 0; + $answer_title = stripslashes( $answer->answer_title ); ?> answer_view_format && 'text_image' !== $answer->answer_view_format ) : ?> diff --git a/templates/single/quiz/parts/fill-in-the-blank.php b/templates/single/quiz/parts/fill-in-the-blank.php index 573ea6b9e5..1fba2d8a35 100644 --- a/templates/single/quiz/parts/fill-in-the-blank.php +++ b/templates/single/quiz/parts/fill-in-the-blank.php @@ -16,8 +16,7 @@ class="quiz-question-ans-choice-area tutor-mt-40 question-type-answer_title ); - $answer->is_correct ? $quiz_answers[] = $answer->answer_id : 0; + $answer_title = stripslashes( $answer->answer_title ); ?>
answer_title ); - $answer->is_correct ? $quiz_answers[] = $answer->answer_id : 0; + $answer_title = stripslashes( $answer->answer_title ); if ( 'image' === $answer->answer_view_format || 'text_image' === $answer->answer_view_format ) { ?>
diff --git a/templates/single/quiz/parts/question.php b/templates/single/quiz/parts/question.php index 1ed11d1fcd..42dbda0ce2 100644 --- a/templates/single/quiz/parts/question.php +++ b/templates/single/quiz/parts/question.php @@ -74,6 +74,7 @@ class="quiz-attempt-single-question quiz-attempt-single-question-question_type ? 'data-h5p-quiz-content-id=' . esc_attr( $question->question_description ) : ''; ?> data-enable-answer-reveal="" data-question_index="" + data-question-id="question_id ); ?>" data-question-type="question_type ); ?>">
From 8c1c11944cdca2c7a1b4f451e415fc10aea91080 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Fri, 2 Oct 2026 18:01:15 +0600 Subject: [PATCH 044/218] Used httphelper for paypal --- .../Paypal/src/Payments/Paypal/Api.php | 47 +++++++++++-------- .../Paypal/src/Payments/Paypal/Helper.php | 25 ++++++++++ .../Paypal/src/Payments/Paypal/Paypal.php | 38 ++++----------- 3 files changed, 60 insertions(+), 50 deletions(-) diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php index 9e947f10a8..3b4c661f03 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php @@ -6,6 +6,7 @@ use Ollyo\PaymentHub\Core\Support\System; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; use Ollyo\PaymentHub\Exceptions\HttpRequestException; +use Tutor\Helpers\HttpHelper; /** * Paypal Api Class @@ -63,13 +64,13 @@ public function __construct( RepositoryContract $config ) { private function getAccessToken(): string { if ( empty( $this->accessToken ) ) { - $requestData = (object) array( - 'method' => 'post', + $requestData = array( 'url' => self::$config->get( 'api_url' ) . '/v1/oauth2/token', 'options' => array( - 'auth' => array( self::$config->get( 'client_id' ), self::$config->get( 'client_secret' ) ), - 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), - 'form_params' => array( 'grant_type' => 'client_credentials' ), + 'auth' => array( self::$config->get( 'client_id' ), self::$config->get( 'client_secret' ) ), + 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'body' => array( 'grant_type' => 'client_credentials' ), + 'method' => HttpHelper::METHOD_POST, ), ); @@ -97,12 +98,12 @@ public static function createOrder( $data, $order_id ): object { self::$headers['PayPal-Request-Id'] = "order-id-{$order_id}"; - $request_data = (object) array( - 'method' => 'post', + $request_data = array( 'url' => self::$config->get( 'api_url' ) . '/v2/checkout/orders', 'options' => array( 'headers' => self::$headers, 'body' => wp_json_encode( $data ), + 'method' => HttpHelper::METHOD_POST, ), ); @@ -125,10 +126,12 @@ public static function capturePayment( $payloadStream ) { self::$headers['PayPal-Request-Id'] = "paypal-order-id-{$payloadStream->id}"; self::$headers['Prefer'] = 'return=representation'; - $requestData = (object) array( - 'method' => 'post', + $requestData = array( 'url' => $capturePaymentUrl, - 'options' => array( 'headers' => self::$headers ), + 'options' => array( + 'headers' => self::$headers, + 'method' => HttpHelper::METHOD_POST, + ), ); System::sendHttpRequest( $requestData ); @@ -157,12 +160,12 @@ public static function webhook_signature_validation( $payload ): bool { 'webhook_event' => $payload_stream, ); - $request_data = (object) array( - 'method' => 'post', + $request_data = array( 'url' => self::$config->get( 'api_url' ) . '/v1/notifications/verify-webhook-signature', 'options' => array( 'headers' => self::$headers, 'body' => wp_json_encode( $data ), + 'method' => HttpHelper::METHOD_POST, ), ); @@ -204,10 +207,12 @@ public static function respond_to_event(): void { public static function get_order_details( $url ): ?object { try { - $request_data = (object) array( - 'method' => 'get', + $request_data = array( 'url' => $url, - 'options' => array( 'headers' => self::$headers ), + 'options' => array( + 'headers' => self::$headers, + 'method' => HttpHelper::METHOD_GET, + ), ); return System::sendHttpRequest( $request_data ); @@ -237,12 +242,12 @@ public static function refund( $refund_url, $order_id, $data ) { self::$headers['PayPal-Request-Id'] = "{$unique_id}-order-id-{$order_id}"; self::$headers['Prefer'] = 'return=representation'; - $request_data = (object) array( - 'method' => 'post', + $request_data = array( 'url' => $refund_url, 'options' => array( 'headers' => self::$headers, 'body' => wp_json_encode( $data ), + 'method' => HttpHelper::METHOD_POST, ), ); @@ -267,10 +272,12 @@ public static function refund( $refund_url, $order_id, $data ) { public static function get_vault_details( $url ): ?object { try { - $request_data = (object) array( - 'method' => 'get', + $request_data = array( 'url' => $url, - 'options' => array( 'headers' => self::$headers ), + 'options' => array( + 'headers' => self::$headers, + 'method' => HttpHelper::METHOD_GET, + ), ); return System::sendHttpRequest( $request_data ); diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php index 65ad1f1789..1f861d8be7 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php @@ -6,6 +6,7 @@ use Ollyo\PaymentHub\Core\Support\System; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; use Ollyo\PaymentHub\Exceptions\HttpRequestException; +use Tutor\Helpers\HttpHelper; /** * Paypal Helper Class @@ -419,4 +420,28 @@ private static function built_payment_source_from_order_details( $previous_paylo ), ); } + + /** + * Retrieves the refund status based on the provided links and type. + * + * @param array $links An array of links provided by the PayPal API. + * @param string|null $type The type of link to use. + * + * @since 1.0.0 + */ + private function getRefundStatus( $links, $type = 'self' ): ?string { + $url = self::getUrl( $links, $type ); + + $requestData = array( + 'url' => $url, + 'options' => array( + 'headers' => self::$headers, + 'method' => HttpHelper::METHOD_GET, + ), + ); + + $responseData = System::sendHttpRequest( $requestData ); + + return strtolower( $responseData->status ) ?? null; + } } diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index 4ddb3b1f6e..51ce0beaf4 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -11,6 +11,7 @@ use Ollyo\PaymentHub\Exceptions\InvalidDataException; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; use Ollyo\PaymentHub\Exceptions\HttpRequestException; +use Tutor\Helpers\HttpHelper; class Paypal extends BasePayment { @@ -113,9 +114,6 @@ public function setData( $data ): void { * @since 3.0.0 */ public function prepareData( $data ): array { - if ( empty( $data ) ) { - return array(); - } $this->orderID = $data->order_id; $type = $data->type ?? 'one-time'; @@ -331,28 +329,6 @@ public function createRefund() { } } - /** - * Retrieves the refund status based on the provided links and type. - * - * @param array $links An array of links provided by the PayPal API. - * @param string|null $type The type of link to use. - * - * @since 1.0.0 - */ - private function getRefundStatus( $links, $type = 'self' ): ?string { - $url = $this->getUrl( $links, $type ); - - $requestData = (object) array( - 'method' => 'get', - 'url' => $url, - 'options' => array( 'headers' => $this->headers ), - ); - - $responseData = System::sendHttpRequest( $requestData ); - - return strtolower( $responseData->status ) ?? null; - } - /** * Creates a webhook for PayPal notifications if it does not already exist. * @@ -367,10 +343,12 @@ public function createWebhook(): ?object { $webhookApiUrl = $this->config->get( 'api_url' ) . '/v1/notifications/webhooks'; - $requestData = (object) array( - 'method' => 'get', + $requestData = array( 'url' => $webhookApiUrl, - 'options' => array( 'headers' => $this->headers ), + 'options' => array( + 'headers' => $this->headers, + 'method' => HttpHelper::METHOD_GET, + ), ); $responseData = System::sendHttpRequest( $requestData ); @@ -411,12 +389,12 @@ private function createNewWebhook() { 'event_types' => array( (object) array( 'name' => 'PAYMENT.CAPTURE.REFUNDED' ) ), ); - $requestData = (object) array( - 'method' => 'post', + $requestData = array( 'url' => $webhookApiUrl, 'options' => array( 'headers' => $this->headers, 'body' => json_encode( $body ), + 'method' => HttpHelper::METHOD_POST, ), ); From f33ae75a75273b79646da040a2d2746119ef13e5 Mon Sep 17 00:00:00 2001 From: Sanjana Khan Date: Sun, 4 Oct 2026 14:55:27 +0600 Subject: [PATCH 045/218] Extra Meta data added for payment gateway --- ecommerce/CheckoutController.php | 2 ++ .../Paypal/src/Core/Support/System.php | 20 ++++++++++++++ .../Paypal/src/Payments/Paypal/Api.php | 6 +++-- .../Paypal/src/Payments/Paypal/Paypal.php | 26 +++++++++++++------ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/ecommerce/CheckoutController.php b/ecommerce/CheckoutController.php index 7172c2b4d9..4291228743 100644 --- a/ecommerce/CheckoutController.php +++ b/ecommerce/CheckoutController.php @@ -887,6 +887,7 @@ public static function prepare_payment_data( array $order ) { 'subtotal' => floatval( $subtotal_price ), 'total_price' => floatval( $total_price ), 'order_id' => $order_id, + 'order_user_id' => $order_user_id, 'store_name' => $site_name, 'order_description' => 'Tutor Order', 'tax' => 0, @@ -976,6 +977,7 @@ public static function prepare_recurring_payment_data( int $order_id ) { 'numeric_code' => $currency_info['numeric_code'] ?? '', ), 'order_id' => $order_id, + 'order_user_id' => $order_user_id, 'customer' => (object) $customer_info, 'shipping_address' => (object) $shipping_and_billing, ); diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index f39df53148..e2fe9747a9 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -274,4 +274,24 @@ public static function sendHttpRequest( $request_data ) { return json_decode( $body ); } + + /** + * Builds the Tutor metadata attached to every payment gateway. + * + * @since 4.1.2 + * + * @param string $env The payment mode. + * @param int $order_user_id The Tutor order ID. + * @return array The metadata as string key/value pairs. + */ + public static function get_tutor_metadata( $env, $order_user_id ): array { + $user = $order_user_id ? get_userdata( $order_user_id ) : false; + + return array( + 'tutor_version' => defined( 'TUTOR_VERSION' ) ? TUTOR_VERSION : '', + 'wp_user' => $user ? "{$user->ID} | {$user->user_email}" : '', + 'site_url' => get_site_url(), + 'env' => $env, + ); + } } diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php index 3b4c661f03..70f1b3b5c4 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php @@ -67,8 +67,10 @@ private function getAccessToken(): string { $requestData = array( 'url' => self::$config->get( 'api_url' ) . '/v1/oauth2/token', 'options' => array( - 'auth' => array( self::$config->get( 'client_id' ), self::$config->get( 'client_secret' ) ), - 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'headers' => array( + 'Authorization' => 'Basic ' . base64_encode( self::$config->get( 'client_id' ) . ':' . self::$config->get( 'client_secret' ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'Content-Type' => 'application/x-www-form-urlencoded', + ), 'body' => array( 'grant_type' => 'client_credentials' ), 'method' => HttpHelper::METHOD_POST, ), diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index 51ce0beaf4..76d84b3856 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -115,18 +115,28 @@ public function setData( $data ): void { */ public function prepareData( $data ): array { - $this->orderID = $data->order_id; - $type = $data->type ?? 'one-time'; - $items = 'one-time' === $type ? Helper::getItems( $data ) : null; - $amount = 'one-time' === $type ? Helper::createAmountData( $data ) : Helper::createAmountForRecurring( $data ); + $this->orderID = $data->order_id; + $type = $data->type ?? 'one-time'; + $items = 'one-time' === $type ? Helper::getItems( $data ) : null; + $amount = 'one-time' === $type ? Helper::createAmountData( $data ) : Helper::createAmountForRecurring( $data ); + $tutor_metadata = System::get_tutor_metadata( $this->config->get( 'mode' ), $data->order_user_id ); + $description = implode( + ',', + array_map( + fn ( $key, $value ) => "{$key}: {$value}", + array_keys( $tutor_metadata ), + $tutor_metadata + ) + ); $returnData = array( 'purchase_units' => array( array( - 'custom_id' => $data->order_id, - 'items' => $items, - 'amount' => $amount, - 'payee' => array( 'email_address' => $this->config->get( 'merchant_email' ) ), + 'custom_id' => $data->order_id, + 'items' => $items, + 'amount' => $amount, + 'payee' => array( 'email_address' => $this->config->get( 'merchant_email' ) ), + 'description' => $description, ), ), 'intent' => 'CAPTURE', From 4237461eeeeefad581c380544ed021d212a93cc7 Mon Sep 17 00:00:00 2001 From: Sanjana Khan Date: Mon, 5 Oct 2026 00:06:14 +0600 Subject: [PATCH 046/218] condition added for invalid order id. --- ecommerce/HooksHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecommerce/HooksHandler.php b/ecommerce/HooksHandler.php index 8a29f9886a..215757ea32 100644 --- a/ecommerce/HooksHandler.php +++ b/ecommerce/HooksHandler.php @@ -197,7 +197,7 @@ public function handle_payment_updated_webhook( $res ) { * * @since 3.9.7 */ - $is_valid_paid_order = OrderModel::ORDER_COMPLETED === $order_details->order_status && OrderModel::PAYMENT_PAID === $order_details->payment_status; + $is_valid_paid_order = $order_details && OrderModel::ORDER_COMPLETED === $order_details->order_status && OrderModel::PAYMENT_PAID === $order_details->payment_status; if ( $order_details && ! $is_valid_paid_order ) { $prev_payment_status = $order_details->payment_status; From f98c9cec2b4342676a1804688e0c892a27383c08 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Mon, 5 Oct 2026 10:39:54 +0600 Subject: [PATCH 047/218] Put and Patch function added --- .../Paypal/src/Core/Support/System.php | 16 ++--- helpers/HttpHelper.php | 72 ++++++++++++++----- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index e2fe9747a9..9a54f5cc5f 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -239,9 +239,6 @@ public static function updateWebhookUrl( $config ): string { * @since 1.0.0 * @since 4.1.2 Sends the request through HttpHelper instead of Guzzle. * - * Supported options: headers, body, form_params, query (GET only), and - * auth as [ user, pass ] for Basic auth or a raw Authorization value. - * * @param array $request_data An object containing the request method (get or post), URL, and options. * * @return object|array|null The decoded JSON response body. @@ -250,11 +247,13 @@ public static function updateWebhookUrl( $config ): string { * @throws HttpRequestException On a transport error or a 4xx/5xx response. */ public static function sendHttpRequest( $request_data ) { - $url = $request_data['url']; - $args = $request_data['options'] ?? array(); + $url = $request_data['url']; + $args = $request_data['options'] ?? array(); + $method = strtoupper( $args['method'] ?? 'GET' ); - $response = HttpHelper::send( $url, $args ); + unset( $args['method'] ); // Remove method from args to avoid conflicts with HttpHelper. + $response = HttpHelper::$method( $url, $args ); if ( $response->has_error() ) { throw new HttpRequestException( esc_html( $response->get_error_message() ) ); } @@ -267,11 +266,6 @@ public static function sendHttpRequest( $request_data ) { $body = (string) $response->get_body(); - // Strip the UTF-8 BOM some gateways (e.g. Authorize.net) prepend to JSON responses. - if ( 0 === strncmp( $body, "\xEF\xBB\xBF", 3 ) ) { - $body = substr( $body, 3 ); - } - return json_decode( $body ); } diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index 6b95c3c1b7..1f2ad064e0 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -109,13 +109,12 @@ private function parse_response( $response ) { */ public static function get( $url, $data = array(), $headers = array() ) { $url_with_params = add_query_arg( $data, $url ); + $args = array( + 'headers' => $headers, + 'method' => self::METHOD_GET, + ); - $response = wp_remote_get( $url_with_params, array( 'headers' => $headers ) ); - - $self = new self(); - $self->parse_response( $response ); - - return $self; + return self::send( $url_with_params, $args ); } /** @@ -128,18 +127,13 @@ public static function get( $url, $data = array(), $headers = array() ) { * @return self */ public static function post( $url, $data = array(), $headers = array() ) { - $response = wp_remote_post( - $url, - array( - 'body' => $data, - 'headers' => $headers, - ) + $args = array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_POST, ); - $self = new self(); - $self->parse_response( $response ); - - return $self; + return self::send( $url, $args ); } /** @@ -196,7 +190,17 @@ public function get_error_message() { return $this->wp_error->get_error_message(); } - public static function send( $url, $args = array() ) { + /** + * Sends an HTTP request and parses the response. + * + * @since 4.1.2 + * + * @param string $url Request URL. + * @param array $args Optional request arguments. + * + * @return self Parsed HTTP response instance. + */ + private static function send( $url, $args = array() ) { $response = wp_remote_request( $url, @@ -208,4 +212,38 @@ public static function send( $url, $args = array() ) { return $self; } + + /** + * Make HTTP PUT request. + * + * @since 4.1.2 + * + * @param string $url The URL for the request. + * @param array $args Optional. The data to include in the request body. + * + * @return self + */ + public static function put( $url, $args = array() ) { + + $args['method'] = self::METHOD_PUT; + + return self::send( $url, $args ); + } + + /** + * Make HTTP PATCH request. + * + * @since 4.1.2 + * + * @param string $url The URL for the request. + * @param array $args Optional. The data to include in the request body. + * + * @return self + */ + public static function patch( $url, $args = array() ) { + + $args['method'] = self::METHOD_PATCH; + + return self::send( $url, $args ); + } } From c4a9b0e6ece79b9db1b2f4996bc1efc83a97f897 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 5 Oct 2026 13:27:44 +0600 Subject: [PATCH 048/218] fix(course-builder): update quiz negative marking settings and mismatch notice condition --- .../NegativeMarkTypeMismatchNotice.tsx | 5 +- .../components/curriculum/QuizSettings.tsx | 121 +++++++----------- 2 files changed, 50 insertions(+), 76 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx index f02007d1c4..863c7ec6e3 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx @@ -60,13 +60,16 @@ const NegativeMarkTypeMismatchNotice = () => { const negativeMarkingEnabled = form.watch('quiz_option.enable_negative_marking'); const negativeMarkValue = form.watch('quiz_option.negative_mark_value'); + const adminNegativeMarkEnabled = tutorConfig.settings?.enable_quiz_negative_marking === 'on'; const adminNegativeMarkValue = tutorConfig.settings.quiz_negative_mark_value; const adminNegativeMarkType = tutorConfig.settings?.quiz_negative_mark_type === QUIZ_NEGATIVE_MARK_TYPES.FIXED ? QUIZ_NEGATIVE_MARK_TYPES.FIXED : QUIZ_NEGATIVE_MARK_TYPES.PERCENT; - const showMismatchNotice = Boolean(negativeMarkingEnabled && adminNegativeMarkType !== negativeMarkType); + const showMismatchNotice = Boolean( + adminNegativeMarkEnabled && negativeMarkingEnabled && adminNegativeMarkType !== negativeMarkType, + ); if (!showMismatchNotice) { return null; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 389a13c696..8e23120787 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -361,55 +361,53 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
-
-
- ( - - )} - /> -
- - { - if (isNaN(Number(value))) return __('Must be a number', 'tutor'); - const numericValue = Number(value); - if (numericValue <= 0) return __('Cannot be less than or equal to 0', 'tutor'); - if (negativeMarkType === QUIZ_NEGATIVE_MARK_TYPES.PERCENT && numericValue > 100) - return __('Cannot be greater than 100', 'tutor'); - return true; - }, - }} - render={(controllerProps) => ( - - )} + ( + - -
+ )} + /> +

+ {__('Deduct a fixed amount for each question with any incorrect answer.', 'tutor')} +

+ + + { + if (isNaN(Number(value))) return __('Must be a number', 'tutor'); + const numericValue = Number(value); + if (numericValue <= 0) return __('Cannot be less than or equal to 0', 'tutor'); + if (negativeMarkType === QUIZ_NEGATIVE_MARK_TYPES.PERCENT && numericValue > 100) + return __('Cannot be greater than 100', 'tutor'); + return true; + }, + }} + render={(controllerProps) => ( + + )} + /> +
@@ -1080,33 +1078,6 @@ const styles = { border-radius: ${borderRadius[8]}; background-color: ${colorTokens.surface.courseBuilder}; `, - negativeMarkingRow: css` - ${styleUtils.display.flex('row')}; - width: 100%; - align-items: flex-start; - justify-content: space-between; - gap: ${spacing[8]}; - min-height: 34px; - `, - negativeMarkingCheckbox: css` - display: flex; - align-items: center; - min-height: 34px; - width: auto; - - [data-cy='form-field-wrapper'] { - width: auto; - } - `, - negativeMarkingFieldWrapper: css` - width: auto; - align-items: flex-end; - margin-left: auto; - - p { - text-align: right; - } - `, negativeMarkingInput: css` max-width: 80px; margin-left: auto; From 3c52a945f5c2f11ab4725580d5d888094f6bfd3d Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Mon, 5 Oct 2026 15:08:39 +0600 Subject: [PATCH 049/218] Simplified req and response --- .../Configs/PaymentUrlsTrait.php | 4 +- .../Paypal/src/Core/Support/System.php | 56 +++++++-- helpers/HttpHelper.php | 112 ++++++++++++++---- 3 files changed, 136 insertions(+), 36 deletions(-) diff --git a/ecommerce/PaymentGateways/Configs/PaymentUrlsTrait.php b/ecommerce/PaymentGateways/Configs/PaymentUrlsTrait.php index bc3c316f72..443c5245a6 100644 --- a/ecommerce/PaymentGateways/Configs/PaymentUrlsTrait.php +++ b/ecommerce/PaymentGateways/Configs/PaymentUrlsTrait.php @@ -42,7 +42,9 @@ public function getSuccessUrl(): string { 'order_id' => 0, ); $args = apply_filters( 'tutor_ecommerce_payment_success_url_args', $args ); - return add_query_arg( $args, home_url() ); + + $url = defined( 'TUTOR_ECOMMERCE_SUCCESS_URL' ) ? TUTOR_ECOMMERCE_SUCCESS_URL : home_url(); + return add_query_arg( $args, $url ); } /** diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index 9a54f5cc5f..04021f30fa 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -239,21 +239,55 @@ public static function updateWebhookUrl( $config ): string { * @since 1.0.0 * @since 4.1.2 Sends the request through HttpHelper instead of Guzzle. * - * @param array $request_data An object containing the request method (get or post), URL, and options. + * @param array|object $request_data Request data with url, optional method, and options. + * @param bool $return_raw_payload Whether to return the raw response object + * instead of the JSON-decoded body. Default false. * * @return object|array|null The decoded JSON response body. * * @throws \InvalidArgumentException If the HTTP method is not supported. * @throws HttpRequestException On a transport error or a 4xx/5xx response. */ - public static function sendHttpRequest( $request_data ) { - $url = $request_data['url']; - $args = $request_data['options'] ?? array(); - $method = strtoupper( $args['method'] ?? 'GET' ); - - unset( $args['method'] ); // Remove method from args to avoid conflicts with HttpHelper. + public static function sendHttpRequest( $request_data, $return_raw_payload = false ) { + $request_data = (object) $request_data; + $url = $request_data->url; + $options = (array) ( $request_data->options ?? array() ); + $method = strtoupper( $options['method'] ?? $request_data->method ?? HttpHelper::METHOD_GET ); + $headers = $options['headers'] ?? array(); + $body = $options['body'] ?? array(); + + unset( $options['method'] ); + + switch ( $method ) { + case HttpHelper::METHOD_GET: + $response = HttpHelper::get( $url, $body, $headers ); + break; + + case HttpHelper::METHOD_POST: + $response = HttpHelper::post( $url, $body, $headers ); + break; + + case HttpHelper::METHOD_PUT: + $response = HttpHelper::put( $url, $body, $headers ); + break; + + case HttpHelper::METHOD_PATCH: + $response = HttpHelper::patch( $url, $body, $headers ); + break; + + case HttpHelper::METHOD_DELETE: + $response = HttpHelper::delete( $url, $body, $headers ); + break; + + default: + $error_message = sprintf( + /* translators: %s: HTTP method name */ + __( 'Unsupported HTTP method: %s', 'tutor' ), + $method + ); + throw new \InvalidArgumentException( esc_html( $error_message ) ); + } - $response = HttpHelper::$method( $url, $args ); if ( $response->has_error() ) { throw new HttpRequestException( esc_html( $response->get_error_message() ) ); } @@ -264,9 +298,11 @@ public static function sendHttpRequest( $request_data ) { throw new HttpRequestException( esc_html( sprintf( 'HTTP %d returned from %s', $status, $url ) ), $status, $response ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } - $body = (string) $response->get_body(); + if ( $return_raw_payload ) { + return $response; + } - return json_decode( $body ); + return json_decode( (string) $response->get_body() ); } /** diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index 1f2ad064e0..f139ca9df4 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -101,36 +101,51 @@ private function parse_response( $response ) { /** * Make HTTP GET request. * - * @param string $url The URL for the request. - * @param array $data Optional. The data to include in the request (added to the URL as query parameters). - * @param array $headers Optional. Additional headers for the request. + * @param string $url Request URL. + * @param array $data Request body. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ - public static function get( $url, $data = array(), $headers = array() ) { - $url_with_params = add_query_arg( $data, $url ); - $args = array( - 'headers' => $headers, - 'method' => self::METHOD_GET, + public static function get( $url, $data = array(), $headers = array(), $args = array() ) { + + if ( ! empty( $data ) ) { + $url = add_query_arg( $data, $url ); + } + + $args = array_merge( + array( + 'headers' => $headers, + 'method' => self::METHOD_GET, + ), + $args ); - return self::send( $url_with_params, $args ); + return self::send( $url, $args ); } /** * Make HTTP POST request. * - * @param string $url The URL for the request. - * @param array $data Optional. The data to include in the request body. - * @param array $headers Optional. Additional headers for the request. + * @param string $url Request URL. + * @param array $data Request body. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ - public static function post( $url, $data = array(), $headers = array() ) { - $args = array( - 'body' => $data, - 'headers' => $headers, - 'method' => self::METHOD_POST, + public static function post( $url, $data = array(), $headers = array(), $args = array() ) { + + $args = array_merge( + array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_POST, + ), + $args ); return self::send( $url, $args ); @@ -218,14 +233,24 @@ private static function send( $url, $args = array() ) { * * @since 4.1.2 * - * @param string $url The URL for the request. - * @param array $args Optional. The data to include in the request body. + * @param string $url Request URL. + * @param array $data Request body. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ - public static function put( $url, $args = array() ) { + public static function put( $url, $data = array(), $headers = array(), $args = array() ) { - $args['method'] = self::METHOD_PUT; + $args = array_merge( + array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_PUT, + ), + $args + ); return self::send( $url, $args ); } @@ -235,14 +260,51 @@ public static function put( $url, $args = array() ) { * * @since 4.1.2 * - * @param string $url The URL for the request. - * @param array $args Optional. The data to include in the request body. + * @param string $url Request URL. + * @param array $data Request body. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ - public static function patch( $url, $args = array() ) { + public static function patch( $url, $data = array(), $headers = array(), $args = array() ) { + + $args = array_merge( + array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_PATCH, + ), + $args + ); - $args['method'] = self::METHOD_PATCH; + return self::send( $url, $args ); + } + + /** + * Make HTTP DELETE request. + * + * @since 4.1.2 + * + * @param string $url Request URL. + * @param array $data Request body. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. + * + * @return self + */ + public static function delete( $url, $data = array(), $headers = array(), $args = array() ) { + + $args = array_merge( + array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_DELETE, + ), + $args + ); return self::send( $url, $args ); } From 4dda691fba6e9bf447849d0a48426b2d9e61f949 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 5 Oct 2026 15:10:43 +0600 Subject: [PATCH 050/218] refactor(restapi): update permission callbacks for enhanced access control - Replaced existing permission callbacks in the RestAPI class with more specific methods to improve access control for various API endpoints. - Introduced new permission methods such as `permission_public_catalog`, `permission_course_detail`, and `permission_authenticated_course_content` to streamline permission handling. - Enhanced the REST_Author class to retrieve published instructor course IDs, ensuring public profile parity. - Updated the REST_Course class to conditionally reveal course content based on user authentication status, improving the user experience for guests and enrolled users. - Added new utility methods in RestAuth for better permission management and user access checks. --- classes/RestAPI.php | 12 +-- restapi/REST_Author.php | 29 +++++- restapi/REST_Course.php | 192 ++++++++++++++++++++++++++++--------- restapi/REST_Lesson.php | 77 ++++++++++----- restapi/REST_Posts.php | 25 +++++ restapi/REST_Rating.php | 43 ++++++++- restapi/REST_Topic.php | 16 ++++ restapi/RestAuth.php | 206 ++++++++++++++++++++++++++++++++++++++-- 8 files changed, 512 insertions(+), 88 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index d6b2625727..01043592dc 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -289,7 +289,7 @@ public function init_routes() { $this->course_obj, 'course', ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_public_catalog' ), ) ); @@ -306,7 +306,7 @@ public function init_routes() { 'args' => array( 'id' => $this->get_id_arg_schema(), ), - 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_detail' ), ) ); @@ -357,7 +357,7 @@ public function init_routes() { 'args' => array( 'id' => $this->get_id_arg_schema(), ), - 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), + 'permission_callback' => array( RestAuth::class, 'permission_authenticated_course_content' ), ) ); @@ -374,7 +374,7 @@ public function init_routes() { 'args' => array( 'topic_id' => $this->get_id_arg_schema(), ), - 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), + 'permission_callback' => array( RestAuth::class, 'permission_authenticated_by_topic' ), ) ); @@ -442,7 +442,7 @@ public function init_routes() { 'args' => array( 'id' => $this->get_id_arg_schema(), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_public_author' ), ) ); @@ -459,7 +459,7 @@ public function init_routes() { 'args' => array( 'id' => $this->get_id_arg_schema(), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_public_rating' ), ) ); diff --git a/restapi/REST_Author.php b/restapi/REST_Author.php index d7f31a02da..26f90bd733 100644 --- a/restapi/REST_Author.php +++ b/restapi/REST_Author.php @@ -10,6 +10,7 @@ namespace TUTOR; +use Tutor\Models\CourseModel; use WP_REST_Request; if ( ! defined( 'ABSPATH' ) ) { @@ -59,7 +60,7 @@ public function author_detail( WP_REST_Request $request ) { 'ID' => $user_data->ID, 'display_name' => $user_data->display_name, 'user_nicename' => $user_data->user_nicename, - 'courses' => get_user_meta( $this->user_id, '_tutor_instructor_course_id', false ), + 'courses' => self::get_published_instructor_course_ids( $this->user_id ), ); if ( RestAuth::can_view_user_private_fields( $this->user_id ) ) { @@ -77,4 +78,30 @@ public function author_detail( WP_REST_Request $request ) { return self::send( $response ); } + + /** + * Published course IDs for an instructor (public profile parity). + * + * @since 4.2.0 + * + * @param int $user_id Instructor user id. + * + * @return int[] + */ + private static function get_published_instructor_course_ids( $user_id ) { + $courses = CourseModel::get_courses_by_instructor( absint( $user_id ), array( 'publish' ) ); + $ids = array(); + + if ( ! is_array( $courses ) ) { + return $ids; + } + + foreach ( $courses as $course ) { + if ( is_object( $course ) && isset( $course->ID ) ) { + $ids[] = (int) $course->ID; + } + } + + return $ids; + } } diff --git a/restapi/REST_Course.php b/restapi/REST_Course.php index e3f0b23d76..dda839fc0e 100644 --- a/restapi/REST_Course.php +++ b/restapi/REST_Course.php @@ -140,45 +140,51 @@ public function course( WP_REST_Request $request ) { continue; } - $item = (object) $post->to_array(); - unset( $item->filter, $item->post_password ); + $is_guest = ! RestAuth::is_rest_authenticated(); - $category = wp_get_post_terms( $post->ID, $this->course_cat_tax ); + if ( $is_guest ) { + $item = self::to_catalog_card_dto( $post ); + } else { + $item = (object) $post->to_array(); + unset( $item->filter, $item->post_password ); - $tag = wp_get_post_terms( $post->ID, $this->course_tag_tax ); + $category = wp_get_post_terms( $post->ID, $this->course_cat_tax ); - $author = get_userdata( $post->post_author ); + $tag = wp_get_post_terms( $post->ID, $this->course_tag_tax ); - if ( $author ) { - $author_payload = (object) array( - 'ID' => $author->ID, - 'display_name' => $author->display_name, - 'user_nicename' => $author->user_nicename, - ); + $author = get_userdata( $post->post_author ); - if ( RestAuth::can_view_user_private_fields( (int) $author->ID ) ) { - $author_payload->user_login = $author->user_login; - $author_payload->user_email = $author->user_email; - $author_payload->user_registered = $author->user_registered; - } + if ( $author ) { + $author_payload = (object) array( + 'ID' => $author->ID, + 'display_name' => $author->display_name, + 'user_nicename' => $author->user_nicename, + ); - $item->post_author = $author_payload; - } else { - $item->post_author = new \stdClass(); - } + if ( RestAuth::can_view_user_private_fields( (int) $author->ID ) ) { + $author_payload->user_login = $author->user_login; + $author_payload->user_email = $author->user_email; + $author_payload->user_registered = $author->user_registered; + } + + $item->post_author = $author_payload; + } else { + $item->post_author = new \stdClass(); + } - $thumbnail_size = apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ); - $item->thumbnail_url = get_the_post_thumbnail_url( $post->ID, $thumbnail_size ); + $thumbnail_size = apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ); + $item->thumbnail_url = get_the_post_thumbnail_url( $post->ID, $thumbnail_size ); - $item->additional_info = $this->course_additional_info( $post->ID ); + $item->additional_info = $this->course_additional_info( $post->ID ); - $item->ratings = tutor_utils()->get_course_rating( $post->ID ); + $item->ratings = tutor_utils()->get_course_rating( $post->ID ); - $item->course_category = $category; + $item->course_category = $category; - $item->course_tag = $tag; + $item->course_tag = $tag; - $item->price = get_post_meta( $post->ID, '_regular_price', true ); + $item->price = get_post_meta( $post->ID, '_regular_price', true ); + } $item = apply_filters( 'tutor_rest_course_single_post', $item ); @@ -213,9 +219,9 @@ public function course( WP_REST_Request $request ) { * @return WP_REST_Response */ public function course_detail( WP_REST_Request $request ) { - $post_id = $request->get_param( 'id' ); + $post_id = absint( $request->get_param( 'id' ) ); - $detail = $this->course_additional_info( $post_id ); + $detail = $this->course_additional_info( $post_id, ! RestAuth::is_rest_authenticated() ); if ( $detail ) { $response = array( 'code' => 'course_detail', @@ -237,36 +243,36 @@ public function course_detail( WP_REST_Request $request ) { * Get course additional info * * @since 2.6.1 + * @since 4.2.0 Guest responses omit internal course_settings. * * @param integer $post_id post id. + * @param bool $for_guest Whether to redact instructor-only settings. * * @return array */ - public function course_additional_info( int $post_id ) { + public function course_additional_info( int $post_id, $for_guest = false ) { $detail = array( - - 'course_settings' => get_post_meta( $post_id, '_tutor_course_settings', false ), - 'course_price_type' => get_post_meta( $post_id, '_tutor_course_price_type', false ), - 'course_duration' => get_post_meta( $post_id, '_course_duration', false ), - 'course_level' => get_post_meta( $post_id, '_tutor_course_level', false ), - 'course_benefits' => get_post_meta( $post_id, '_tutor_course_benefits', false ), - 'course_requirements' => get_post_meta( $post_id, '_tutor_course_requirements', false ), - 'course_target_audience' => get_post_meta( $post_id, '_tutor_course_target_audience', false ), - 'course_material_includes' => get_post_meta( $post_id, '_tutor_course_material_includes', false ), - 'video' => get_post_meta( $post_id, '_video', false ), - 'disable_qa' => get_post_meta( $post_id, '_tutor_enable_qa', true ) != 'yes', ); - return apply_filters( 'tutor_course_additional_info', $detail ); + if ( ! $for_guest ) { + $detail = array_merge( + array( + 'course_settings' => get_post_meta( $post_id, '_tutor_course_settings', false ), + ), + $detail + ); + } + + return apply_filters( 'tutor_course_additional_info', $detail, $post_id, $for_guest ); } /** @@ -307,8 +313,9 @@ public function validate_terms( array $post ) { * @return WP_REST_Response */ public function course_contents( WP_REST_Request $request ) { - $course_id = $request->get_param( 'id' ); - $topics = tutor_utils()->get_topics( + $course_id = absint( $request->get_param( 'id' ) ); + $reveal_bodies = RestAuth::can_reveal_learning_payload( $course_id ); + $topics = tutor_utils()->get_topics( $course_id, array( 'post_status' => 'publish', @@ -321,7 +328,7 @@ public function course_contents( WP_REST_Request $request ) { $current_topic = array( 'id' => $topic->ID, 'title' => $topic->post_title, - 'summary' => $topic->post_content, + 'summary' => $reveal_bodies ? $topic->post_content : '', 'contents' => array(), ); @@ -337,7 +344,9 @@ public function course_contents( WP_REST_Request $request ) { foreach ( $topic_contents->get_posts() as $content_post ) { array_push( $current_topic['contents'], - self::sanitize_course_content_item( $content_post ) + $reveal_bodies + ? self::sanitize_course_content_item( $content_post ) + : self::sanitize_course_content_outline_item( $content_post ) ); } } @@ -382,6 +391,97 @@ private static function sanitize_course_content_item( WP_Post $post ) { ); } + /** + * Outline-only curriculum item (guest / locked parity with course-topics UI). + * + * @since 4.2.0 + * + * @param WP_Post $post Curriculum post. + * + * @return array + */ + private static function sanitize_course_content_outline_item( WP_Post $post ) { + $item = array( + 'ID' => (int) $post->ID, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + 'post_type' => $post->post_type, + 'menu_order' => (int) $post->menu_order, + ); + + $video_info = tutor_utils()->get_video_info( $post->ID ); + if ( $video_info && ! empty( $video_info->playtime ) ) { + $item['duration'] = $video_info->playtime; + } + + return $item; + } + + /** + * Guest catalog card DTO (archive loop parity). + * + * @since 4.2.0 + * + * @param WP_Post $post Course post. + * + * @return object + */ + private function to_catalog_card_dto( WP_Post $post ) { + $author = get_userdata( $post->post_author ); + $ratings = tutor_utils()->get_course_rating( $post->ID ); + + $item = (object) array( + 'ID' => (int) $post->ID, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + 'thumbnail_url' => get_the_post_thumbnail_url( $post->ID, apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ) ), + 'price' => get_post_meta( $post->ID, '_regular_price', true ), + 'post_author' => $author + ? (object) array( + 'ID' => (int) $author->ID, + 'display_name' => $author->display_name, + ) + : new \stdClass(), + 'ratings' => (object) array( + 'rating_count' => isset( $ratings->rating_count ) ? $ratings->rating_count : 0, + 'rating_avg' => isset( $ratings->rating_avg ) ? $ratings->rating_avg : 0, + ), + 'course_category' => self::terms_to_name_dto( wp_get_post_terms( $post->ID, $this->course_cat_tax ) ), + 'course_tag' => self::terms_to_name_dto( wp_get_post_terms( $post->ID, $this->course_tag_tax ) ), + ); + + return $item; + } + + /** + * Reduce WP_Term objects to id + name (card UI). + * + * @since 4.2.0 + * + * @param array|\WP_Error $terms Terms. + * + * @return array + */ + private static function terms_to_name_dto( $terms ) { + if ( ! is_array( $terms ) ) { + return array(); + } + + $out = array(); + foreach ( $terms as $term ) { + if ( ! is_object( $term ) ) { + continue; + } + $out[] = (object) array( + 'term_id' => (int) $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + } + + return $out; + } + /** * Whitelist WP_Query order direction for the course list. * diff --git a/restapi/REST_Lesson.php b/restapi/REST_Lesson.php index 33be39b0b9..b315e44522 100644 --- a/restapi/REST_Lesson.php +++ b/restapi/REST_Lesson.php @@ -48,6 +48,9 @@ public function __construct() { /** * Get lessons for a specific topic. * + * Guests / non-enrolled users on public paid courses receive an outline + * (title + duration) matching the single-course curriculum UI. + * * @param WP_REST_Request $request REST request object. * * @return mixed @@ -64,10 +67,17 @@ public function topic_lesson( WP_REST_Request $request ) { return self::send( $response ); } + $topic_id = absint( $this->post_parent ); + $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); + $reveal_bodies = RestAuth::can_reveal_learning_payload( $course_id ); + $args = array( 'post_type' => $this->post_type, - 'post_parent' => $this->post_parent, + 'post_parent' => $topic_id, + 'post_status' => 'publish', 'posts_per_page' => -1, + 'orderby' => 'menu_order', + 'order' => 'ASC', ); $lessons_query = new WP_Query( $args ); @@ -77,31 +87,11 @@ public function topic_lesson( WP_REST_Request $request ) { if ( $lessons_query->have_posts() ) { $posts = $lessons_query->get_posts(); foreach ( $posts as $post ) { - - $lesson = new \stdClass(); - - $lesson->ID = $post->ID; - $lesson->post_title = $post->post_title; - $lesson->post_content = $post->post_content; - $lesson->post_name = $post->post_name; - $lesson->topic_id = wp_get_post_parent_id( $lesson->ID ); - - $attachments = array(); - $attachments_id = get_post_meta( $lesson->ID, '_tutor_attachments', false ); - if ( is_array( $attachments_id ) && count( $attachments_id ) > 0 ) { - $attachments_id = $attachments_id[0]; - - foreach ( $attachments_id as $id ) { - $guid = get_the_guid( $id ); - array_push( $attachments, $guid ); - } + if ( $reveal_bodies ) { + $data[] = self::to_full_lesson_dto( $post, $topic_id ); + } else { + $data[] = REST_Posts::to_outline_post_dto( $post ); } - - $lesson->attachments = $attachments; - $lesson->thumbnail = get_the_post_thumbnail_url( $lesson->ID ); - $lesson->video = get_post_meta( $lesson->ID, '_video', false ); - - array_push( $data, $lesson ); } $response = array( @@ -121,4 +111,41 @@ public function topic_lesson( WP_REST_Request $request ) { return self::send( $response ); } + + /** + * Full lesson payload (enrolled / public-free / instructor). + * + * @since 4.2.0 + * + * @param \WP_Post $post Lesson post. + * @param int $topic_id Topic id. + * + * @return object + */ + private static function to_full_lesson_dto( $post, $topic_id ) { + $lesson = new \stdClass(); + + $lesson->ID = $post->ID; + $lesson->post_title = $post->post_title; + $lesson->post_content = $post->post_content; + $lesson->post_name = $post->post_name; + $lesson->topic_id = absint( $topic_id ); + + $attachments = array(); + $attachments_id = get_post_meta( $lesson->ID, '_tutor_attachments', false ); + if ( is_array( $attachments_id ) && count( $attachments_id ) > 0 ) { + $attachments_id = $attachments_id[0]; + + foreach ( $attachments_id as $id ) { + $guid = get_the_guid( $id ); + array_push( $attachments, $guid ); + } + } + + $lesson->attachments = $attachments; + $lesson->thumbnail = get_the_post_thumbnail_url( $lesson->ID ); + $lesson->video = get_post_meta( $lesson->ID, '_video', false ); + + return $lesson; + } } diff --git a/restapi/REST_Posts.php b/restapi/REST_Posts.php index d10ff908ce..958d643c7d 100644 --- a/restapi/REST_Posts.php +++ b/restapi/REST_Posts.php @@ -39,6 +39,31 @@ public static function to_public_post_dto( WP_Post $post ) { ); } + /** + * Curriculum outline DTO (titles only — guest / locked curriculum parity). + * + * @since 4.2.0 + * + * @param WP_Post $post Post object. + * + * @return object + */ + public static function to_outline_post_dto( WP_Post $post ) { + $dto = (object) array( + 'ID' => (int) $post->ID, + 'post_title' => $post->post_title, + 'post_name' => $post->post_name, + 'post_type' => $post->post_type, + ); + + $video_info = tutor_utils()->get_video_info( $post->ID ); + if ( $video_info && ! empty( $video_info->playtime ) ) { + $dto->duration = $video_info->playtime; + } + + return $dto; + } + /** * Fetch published child posts as public DTOs. * diff --git a/restapi/REST_Rating.php b/restapi/REST_Rating.php index 08b2a029ae..f59e8aa2cb 100644 --- a/restapi/REST_Rating.php +++ b/restapi/REST_Rating.php @@ -10,6 +10,7 @@ namespace TUTOR; +use Tutor\Models\CourseModel; use WP_REST_Request; if ( ! defined( 'ABSPATH' ) ) { @@ -53,6 +54,7 @@ class REST_Rating { * Retrieve course ratings via REST API. * * @since 1.7.1 + * @since 4.2.0 Guest payload matches reviews UI; require published course. * * @param WP_REST_Request $request The REST request object. * @@ -64,11 +66,50 @@ public function course_rating( WP_REST_Request $request ) { $limit = (int) sanitize_text_field( $request->get_param( 'limit' ) ); $offset = ! empty( $offset ) ? $offset : 0; - $limit = ! empty( $limit ) ? $limit : 10; + $limit = ! empty( $limit ) ? min( $limit, 100 ) : 10; + + if ( ! CourseModel::get_post_types( $this->post_id ) || 'publish' !== get_post_status( $this->post_id ) ) { + $response = array( + 'code' => 'not_found', + 'message' => __( 'Course not found', 'tutor' ), + 'data' => array(), + ); + return self::send( $response ); + } $ratings = tutor_utils()->get_course_rating( $this->post_id ); $reviews = tutor_utils()->get_course_reviews( $this->post_id, $offset, $limit, false, array( 'approved' ) ); + $is_guest = ! RestAuth::is_rest_authenticated(); + + if ( $is_guest ) { + $payload = (object) array( + 'rating_count' => isset( $ratings->rating_count ) ? $ratings->rating_count : 0, + 'rating_avg' => isset( $ratings->rating_avg ) ? $ratings->rating_avg : 0, + 'count_by_value' => isset( $ratings->count_by_value ) ? $ratings->count_by_value : array(), + 'reviews' => array(), + ); + + if ( is_array( $reviews ) ) { + foreach ( $reviews as $review ) { + $payload->reviews[] = (object) array( + 'display_name' => isset( $review->display_name ) ? $review->display_name : '', + 'comment_content' => isset( $review->comment_content ) ? $review->comment_content : '', + 'comment_date_gmt' => isset( $review->comment_date_gmt ) ? $review->comment_date_gmt : '', + 'rating' => isset( $review->rating ) ? $review->rating : 0, + ); + } + } + + $response = array( + 'code' => 'success', + 'message' => __( 'Course rating retrieved successfully', 'tutor' ), + 'data' => $payload, + ); + + return self::send( $response ); + } + $ratings->reviews = is_array( $reviews ) ? $reviews : array(); foreach ( $ratings->reviews as $review ) { diff --git a/restapi/REST_Topic.php b/restapi/REST_Topic.php index 7c18a5c499..bb27d38144 100644 --- a/restapi/REST_Topic.php +++ b/restapi/REST_Topic.php @@ -61,8 +61,24 @@ public function course_topic( WP_REST_Request $request ) { return self::send( $response ); } + $course_id = absint( $this->post_parent ); + $reveal_bodies = RestAuth::can_reveal_learning_payload( $course_id ); + $result = REST_Posts::get_published_child_posts( $this->post_type, $this->post_parent ); + if ( ! $reveal_bodies ) { + $result = array_map( + static function ( $topic ) { + return (object) array( + 'ID' => (int) $topic->ID, + 'post_title' => $topic->post_title, + 'post_name' => $topic->post_name, + ); + }, + $result + ); + } + if ( count( $result ) > 0 ) { $response = array( 'code' => 'get_topic', diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 18c8a84794..e6e552e731 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1043,7 +1043,53 @@ public static function enforce_actor_identity( $response, $handler, $request ) { } /** - * Permission: valid API key and may view course learning content. + * Permission: published course catalog (anonymous allowed). + * + * @since 4.2.0 + * + * @return bool + */ + public static function permission_public_catalog() { + return true; + } + + /** + * Permission: course ratings / reviews (anonymous allowed). + * + * @since 4.2.0 + * + * @return bool + */ + public static function permission_public_rating() { + return true; + } + + /** + * Whether a user's public profile layout is open (not private). + * + * Mirrors templates/public-profile.php layout keys. + * + * @since 4.2.0 + * + * @param int $user_id user id. + * + * @return bool + */ + public static function is_public_profile_layout_open( $user_id ) { + $user_id = absint( $user_id ); + if ( ! $user_id || ! get_userdata( $user_id ) ) { + return false; + } + + $is_instructor = tutor_utils()->is_instructor( $user_id, true ); + $layout_key = $is_instructor ? 'public_profile_layout' : 'student_public_profile_layout'; + $profile_layout = tutor_utils()->get_option( $layout_key, 'private' ); + + return 'private' !== $profile_layout; + } + + /** + * Permission: author card — JWT/key or open public-profile layout. * * @since 4.2.0 * @@ -1051,21 +1097,62 @@ public static function enforce_actor_identity( $response, $handler, $request ) { * * @return bool */ - public static function permission_course_content( WP_REST_Request $request ) { - if ( ! static::process_api_request() ) { + public static function permission_public_author( WP_REST_Request $request ) { + if ( static::process_api_request() ) { + return true; + } + + return static::is_public_profile_layout_open( absint( $request->get_param( 'id' ) ) ); + } + + /** + * Permission: course marketing detail (anonymous with website login option). + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_course_detail( WP_REST_Request $request ) { + $course_id = absint( $request->get_param( 'id' ) ); + if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { return false; } + if ( static::process_api_request() ) { + return static::can_view_course_content( $course_id ); + } + + if ( Course_List::is_public( $course_id ) ) { + return true; + } + + return ! tutor_utils()->get_option( 'student_must_login_to_view_course' ); + } + + /** + * Resolve course id from common REST route params. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return int + */ + private static function course_id_from_request( WP_REST_Request $request ) { $course_id = absint( $request->get_param( 'id' ) ); if ( ! $course_id ) { $course_id = absint( $request->get_param( 'course_id' ) ); } - return static::can_view_course_content( $course_id ); + return $course_id; } /** - * Permission: topics by course_id. + * Permission: course learning content — JWT path or public course (no token). + * + * Used by course-contents. Announcements use permission_authenticated_course_content. * * @since 4.2.0 * @@ -1073,16 +1160,54 @@ public static function permission_course_content( WP_REST_Request $request ) { * * @return bool */ - public static function permission_topics( WP_REST_Request $request ) { + public static function permission_course_content( WP_REST_Request $request ) { + $course_id = self::course_id_from_request( $request ); + + if ( static::process_api_request() ) { + return static::can_view_course_content( $course_id ); + } + + return $course_id && Course_List::is_public( $course_id ); + } + + /** + * Permission: course content that always requires a valid access token. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_authenticated_course_content( WP_REST_Request $request ) { if ( ! static::process_api_request() ) { return false; } - return static::can_view_course_content( absint( $request->get_param( 'course_id' ) ) ); + return static::can_view_course_content( self::course_id_from_request( $request ) ); + } + + /** + * Permission: topics by course_id — JWT or public course. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_topics( WP_REST_Request $request ) { + $course_id = absint( $request->get_param( 'course_id' ) ); + + if ( static::process_api_request() ) { + return static::can_view_course_content( $course_id ); + } + + return $course_id && Course_List::is_public( $course_id ); } /** - * Permission: lessons or quizzes listed by topic_id. + * Permission: lessons or quizzes listed by topic_id — JWT or public course. * * @since 4.2.0 * @@ -1091,6 +1216,26 @@ public static function permission_topics( WP_REST_Request $request ) { * @return bool */ public static function permission_by_topic( WP_REST_Request $request ) { + $topic_id = absint( $request->get_param( 'topic_id' ) ); + $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); + + if ( static::process_api_request() ) { + return static::can_view_course_content( $course_id ); + } + + return $course_id && Course_List::is_public( $course_id ); + } + + /** + * Permission: topic children that always require a valid access token (e.g. quiz list). + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_authenticated_by_topic( WP_REST_Request $request ) { if ( ! static::process_api_request() ) { return false; } @@ -1102,7 +1247,7 @@ public static function permission_by_topic( WP_REST_Request $request ) { } /** - * Permission: quiz by quiz id. + * Permission: quiz by quiz id (always requires access token). * * @since 4.2.0 * @@ -1153,6 +1298,49 @@ public static function can_view_course_content( $course_id, $user_id = 0 ) { return (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); } + /** + * Whether full lesson/quiz bodies, video, and attachments may be returned. + * + * Matches Template::load_single_lesson_template: enrolled / instructor-admin, + * or a public course that is not purchasable (guest-readable lessons). + * + * @since 4.2.0 + * + * @param int $course_id course id. + * @param int $user_id user id. + * + * @return bool + */ + public static function can_reveal_learning_payload( $course_id, $user_id = 0 ) { + $course_id = absint( $course_id ); + if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { + return false; + } + + $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); + + if ( $user_id && EnrollmentModel::is_enrolled( $course_id, $user_id ) ) { + return true; + } + + if ( $user_id && tutor_utils()->has_user_course_content_access( $user_id, $course_id ) ) { + return true; + } + + return Course_List::is_public( $course_id ) && ! tutor_utils()->is_course_purchasable( $course_id ); + } + + /** + * Whether this Tutor REST call has a valid access JWT (key permission). + * + * @since 4.2.0 + * + * @return bool + */ + public static function is_rest_authenticated() { + return static::process_api_request(); + } + /** * Whether answer keys (is_correct) may be revealed. * From 56fd5016d94be7662032d7ba0aefb830a02b7749 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Mon, 5 Oct 2026 15:18:43 +0600 Subject: [PATCH 051/218] Warning message fix --- helpers/HttpHelper.php | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index f139ca9df4..b7472d0a5d 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -129,11 +129,11 @@ public static function get( $url, $data = array(), $headers = array(), $args = a /** * Make HTTP POST request. * - * @param string $url Request URL. - * @param array $data Request body. Default empty array. - * @param array $headers Request headers. Default empty array. - * @param array $args Additional arguments passed through to send(), - * merged over the defaults. Default empty array. + * @param string $url Request URL. + * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ @@ -233,11 +233,11 @@ private static function send( $url, $args = array() ) { * * @since 4.1.2 * - * @param string $url Request URL. - * @param array $data Request body. Default empty array. - * @param array $headers Request headers. Default empty array. - * @param array $args Additional arguments passed through to send(), - * merged over the defaults. Default empty array. + * @param string $url Request URL. + * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ @@ -260,11 +260,11 @@ public static function put( $url, $data = array(), $headers = array(), $args = a * * @since 4.1.2 * - * @param string $url Request URL. - * @param array $data Request body. Default empty array. - * @param array $headers Request headers. Default empty array. - * @param array $args Additional arguments passed through to send(), - * merged over the defaults. Default empty array. + * @param string $url Request URL. + * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ @@ -287,11 +287,11 @@ public static function patch( $url, $data = array(), $headers = array(), $args = * * @since 4.1.2 * - * @param string $url Request URL. - * @param array $data Request body. Default empty array. - * @param array $headers Request headers. Default empty array. - * @param array $args Additional arguments passed through to send(), - * merged over the defaults. Default empty array. + * @param string $url Request URL. + * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. + * @param array $headers Request headers. Default empty array. + * @param array $args Additional arguments passed through to send(), + * merged over the defaults. Default empty array. * * @return self */ From bdb12d49b7574053de4a0df9066e1fa5867dab70 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 5 Oct 2026 15:30:12 +0600 Subject: [PATCH 052/218] Add full support for v4 design system in registration forms This change updates the student and instructor registration screens to use the v4 component system (InputField and Button), adds consistent spacing and login/register CTA styling, and standardizes access-denied/feature-disabled templates with the shared design-system styles. It also fixes Alpine truthiness checks for empty-string values in select, clear-button, and password-strength states to behave correctly when values are empty. --- .../scss/components/_feature-disabled.scss | 37 ++++ assets/core/scss/components/_index.scss | 1 + .../dashboard/instructor/registration.php | 182 ++++++++++-------- templates/dashboard/registration.php | 152 ++++++++------- 4 files changed, 222 insertions(+), 150 deletions(-) create mode 100644 assets/core/scss/components/_feature-disabled.scss diff --git a/assets/core/scss/components/_feature-disabled.scss b/assets/core/scss/components/_feature-disabled.scss new file mode 100644 index 0000000000..228a28a41e --- /dev/null +++ b/assets/core/scss/components/_feature-disabled.scss @@ -0,0 +1,37 @@ +// Feature Disabled / Access Denied Component +// +// Ported from legacy assets/src/scss/front/_denied.scss into the v4 design system. +// Used by templates/feature_disabled.php to render access-denied and feature-disabled +// states (e.g. when user registration is disabled) with proper v4 tokens and typography. + +@use '../tokens' as *; +@use '../mixins' as *; + +.tutor-disabled-wrapper { + max-width: 444px; + padding: $tutor-spacing-12 $tutor-spacing-none $tutor-spacing-8; + margin: auto; + @include tutor-flex(column, center, center); + text-align: center; + + .tutor-disabled-content-wrapper { + @include tutor-flex(column, center, center); + text-align: center; + + img { + margin-bottom: $tutor-spacing-6; + } + + h3 { + @include tutor-typography('h4', 'medium', 'primary'); + margin-top: $tutor-spacing-none; + margin-bottom: $tutor-spacing-3; + } + + p { + @include tutor-typography('p2', 'regular', 'subdued'); + margin-top: $tutor-spacing-none; + margin-bottom: $tutor-spacing-8; + } + } +} diff --git a/assets/core/scss/components/_index.scss b/assets/core/scss/components/_index.scss index 1cc2c9a217..312b10bbeb 100644 --- a/assets/core/scss/components/_index.scss +++ b/assets/core/scss/components/_index.scss @@ -28,3 +28,4 @@ @forward 'time-input'; @forward 'status-select'; @forward 'star-rating'; +@forward 'feature-disabled'; diff --git a/templates/dashboard/instructor/registration.php b/templates/dashboard/instructor/registration.php index 6c5a72232a..f9988c432e 100644 --- a/templates/dashboard/instructor/registration.php +++ b/templates/dashboard/instructor/registration.php @@ -18,25 +18,25 @@ tutor()->url . 'assets/images/construction.png', - 'title' => __( 'Ooh! Access Denied', 'tutor' ), - 'description' => __( 'You do not have access to this area of the application. Please refer to your system administrator.', 'tutor' ), - 'button' => array( - 'text' => __( 'Go to Home', 'tutor' ), - 'url' => get_home_url(), - 'class' => 'tutor-btn', - ), - ); - tutor_load_template( 'feature_disabled', $args ); - ?> + $args = array( + 'image_path' => tutor()->url . 'assets/images/construction.png', + 'title' => __( 'Ooh! Access Denied', 'tutor' ), + 'description' => __( 'You do not have access to this area of the application. Please refer to your system administrator.', 'tutor' ), + 'button' => array( + 'text' => __( 'Go to Home', 'tutor' ), + 'url' => get_home_url(), + 'class' => 'tutor-btn tutor-btn-primary', + ), + ); + tutor_load_template( 'feature_disabled', $args ); + ?> -
+
-
+ @@ -51,85 +51,106 @@ ->text( $error_value ) ->variant( Alert::ERROR ) ->icon( Icon::WARNING ) - ->attr( 'class', 'tutor-mb-8' ) ->render(); - endforeach; - endif; + endforeach; + endif; ?> -
- - + +
+ +
+ +
-
- - +
+ +
+ +
-
- - +
+ +
+ +
-
- - +
+ +
+ +
-
-
- -
- - - - +
+
+ +
+
+ +
-
- -
+
+ +
+
+ +
-
- -
+
@@ -139,26 +160,33 @@ class="tutor-form-control tutor-input" $tutor_toc_page_link = tutor_utils()->get_toc_page_link(); $consents = LegalConsent::get_consent_by_display_key( LegalConsent::DISPLAY_ON_INS_REG ); if ( tutor_utils()->count( $consents ) ) : + foreach ( $consents as $consent ) : + LegalConsent::render_consent_field( $consent ); + endforeach; + elseif ( $tutor_toc_page_link ) : ?> - - - - - -
-
-
- - -
-
+
+
+ +
- +
- +
+ +
+
+ +
+ + + +
+
+ diff --git a/templates/dashboard/registration.php b/templates/dashboard/registration.php index 2ee55294de..66a317e15a 100644 --- a/templates/dashboard/registration.php +++ b/templates/dashboard/registration.php @@ -27,17 +27,17 @@ 'button' => array( 'text' => __( 'Go to Home', 'tutor' ), 'url' => get_home_url(), - 'class' => 'tutor-btn', + 'class' => 'tutor-btn tutor-btn-primary', ), ); tutor_load_template( 'feature_disabled', $args ); ?> -
+
-
+ @@ -52,127 +52,133 @@ ->text( $validation_error ) ->variant( Alert::ERROR ) ->icon( Icon::WARNING ) - ->attr( 'class', 'tutor-mb-8' ) ->render(); endforeach; endif; ?> -
- -
- + +
+ +
+
-
- -
- +
+ +
+
-
- -
- +
+ +
+
-
- -
- +
+ +
+
-
- -
- - - - +
+ +
+
+ +
-
- -
-
- + +
+ +
+
-
-
- + get_toc_page_link(); $consents = LegalConsent::get_consent_by_display_key( LegalConsent::DISPLAY_ON_STD_REG ); if ( tutor_utils()->count( $consents ) ) : - + foreach ( $consents as $consent ) : + LegalConsent::render_consent_field( $consent ); + endforeach; + elseif ( $tutor_toc_page_link ) : ?> - - - - - - -
-
-
- - -
-
+
+
+ +
- +
+
-
+
From 699a9396538c750f4af1824c239eaf32d4a8ee21 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Mon, 5 Oct 2026 15:31:51 +0600 Subject: [PATCH 053/218] docs(quiz): Update input label --- .../course-builder/components/curriculum/QuizSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 8e23120787..5286a62f97 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -397,7 +397,7 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { {...controllerProps} type="number" size="small" - label={__('Penalty per incorrect question', 'tutor')} + label={__('Penalty per incorrect answer', 'tutor')} isInlineLabel disabled={!adminNegativeEnabled} wrapperCss={styles.negativeMarkingInput} From cdbda2541ff99b1eec7f6f5f8140473cda686063 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Mon, 5 Oct 2026 15:55:45 +0600 Subject: [PATCH 054/218] version tag added --- helpers/HttpHelper.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index b7472d0a5d..68253904e8 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -101,6 +101,8 @@ private function parse_response( $response ) { /** * Make HTTP GET request. * + * @since 4.1.2 param $args added + * * @param string $url Request URL. * @param array $data Request body. Default empty array. * @param array $headers Request headers. Default empty array. @@ -129,6 +131,8 @@ public static function get( $url, $data = array(), $headers = array(), $args = a /** * Make HTTP POST request. * + * @since 4.1.2 param $args added + * * @param string $url Request URL. * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. * @param array $headers Request headers. Default empty array. From a65cd076fcf79b93e16f0b757bd47e8c6f2bcfd3 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 5 Oct 2026 15:59:12 +0600 Subject: [PATCH 055/218] Enhance API permission handling in RestAuth - Added validation to reject permissions not in the available_permissions allowlist. - Introduced a new private method `is_allowed_api_permission` to check if a permission string is valid. - Updated existing methods to utilize the new permission validation, improving security and access control for API key generation and permission updates. - Marked changes with versioning for clarity on enhancements made in version 4.2.0. --- restapi/RestAuth.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index e6e552e731..641942af9c 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -501,6 +501,7 @@ private static function auth_path_matches( $segment ) { * Generate api keys * * @since 2.2.1 + * @since 4.2.0 Reject permissions not in available_permissions(). * * @return void send wp_json response */ @@ -517,6 +518,10 @@ public static function generate_api_keys() { $permission = Input::post( 'permission' ); $description = Input::post( 'description', '', Input::TYPE_TEXTAREA ); + if ( ! self::is_allowed_api_permission( $permission ) ) { + wp_send_json_error( __( 'Invalid permission.', 'tutor' ) ); + } + $info = wp_json_encode( array( 'key' => $api_key, @@ -544,6 +549,7 @@ public static function generate_api_keys() { * Update api permission * * @since 2.5.0 + * @since 4.2.0 Reject permissions not in available_permissions(). * * @return void send wp_json response */ @@ -560,6 +566,10 @@ public static function update_api_permission() { $permission = Input::post( 'permission' ); $description = Input::post( 'description', '', Input::TYPE_TEXTAREA ); + if ( ! self::is_allowed_api_permission( $permission ) ) { + wp_send_json_error( __( 'Invalid permission.', 'tutor' ) ); + } + $info = QueryHelper::get_row( $wpdb->usermeta, array( 'umeta_id' => $meta_id ), 'umeta_id' ); $meta_value = json_decode( $info->meta_value ); @@ -1685,6 +1695,27 @@ public static function available_permissions(): array { return apply_filters( 'tutor_rest_api_permissions', $permissions ); } + /** + * Whether a permission string is in the available_permissions allowlist. + * + * Core only offers Read; Pro may expand via tutor_rest_api_permissions. + * + * @since 4.2.0 + * + * @param string $permission Permission value from the request. + * + * @return bool + */ + private static function is_allowed_api_permission( $permission ): bool { + if ( ! is_string( $permission ) || '' === $permission ) { + return false; + } + + $allowed = array_column( self::available_permissions(), 'value' ); + + return in_array( $permission, $allowed, true ); + } + /** * Build login/refresh response payload. * From f74973acb847550f6e7a07a5e099b1439e4ffd8f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 5 Oct 2026 16:03:55 +0600 Subject: [PATCH 056/218] Refactor permission handling in RestAuth class - Updated the `permission_public_author` method to clarify permission logic for author cards. - Revised comments to specify the conditions under which layouts are accessible, enhancing documentation for future reference. - Removed redundant permission checks to streamline the permission validation process. --- restapi/RestAuth.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 641942af9c..196bb655c1 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1099,7 +1099,10 @@ public static function is_public_profile_layout_open( $user_id ) { } /** - * Permission: author card — JWT/key or open public-profile layout. + * Permission: author card — same gate as public-profile.php. + * + * Open instructor/student layout → allow (anonymous or JWT). + * Private layout or unknown user → deny (JWT does not bypass). * * @since 4.2.0 * @@ -1108,10 +1111,6 @@ public static function is_public_profile_layout_open( $user_id ) { * @return bool */ public static function permission_public_author( WP_REST_Request $request ) { - if ( static::process_api_request() ) { - return true; - } - return static::is_public_profile_layout_open( absint( $request->get_param( 'id' ) ) ); } From 5458c8fee4af6a07f868ec057b4de41091f46225 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 5 Oct 2026 16:31:24 +0600 Subject: [PATCH 057/218] $this->json_response used instead of wp_send_json --- assets/src/js/front/course/_spotlight-quiz.js | 4 ++-- .../src/js/frontend/learning-area/quiz/layout.ts | 12 ++++++------ classes/Quiz.php | 16 +++++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index 2a0d8494ad..5fc275af3a 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -289,7 +289,7 @@ window.jQuery(document).ready($ => { answers: checkedAnswers, }, success: function (res) { - if (res && res.success && res.data) { + if (res && res.data) { feedback_response($question_wrap, res.data.correct_answer_ids, res.data.answer_explanation); } }, @@ -443,7 +443,7 @@ window.jQuery(document).ready($ => { answers: checkedAnswers, }, success: function (res) { - if (res && res.success && res.data) { + if (res && res.data) { feedback_response($lastQuestion, res.data.correct_answer_ids, res.data.answer_explanation); } }, diff --git a/assets/src/js/frontend/learning-area/quiz/layout.ts b/assets/src/js/frontend/learning-area/quiz/layout.ts index bfe8fa537c..061a5f5e3f 100644 --- a/assets/src/js/frontend/learning-area/quiz/layout.ts +++ b/assets/src/js/frontend/learning-area/quiz/layout.ts @@ -1,4 +1,4 @@ -import type { AlpineComponentMeta } from '@Core/ts/types'; +import type { AjaxResponse, AlpineComponentMeta } from '@Core/ts/types'; import endpoints from '@Core/ts/utils/endpoints'; import { QUIZ_LAYOUT_KEYS, QUIZ_LAYOUT_SELECTORS, QUIZ_REVEAL_CONFIG, QuizLayoutType } from './constants'; @@ -17,6 +17,7 @@ export interface QuizLayoutConfig { const quizLayout = (config: QuizLayoutConfig) => { const { form } = window.TutorCore; const { tutorConfig } = window.TutorCore.config; + const { wpPost } = window.TutorCore.api; let container: Element | null | undefined = null; let handleFirstTab: ((e: KeyboardEvent) => void) | null = null; @@ -435,15 +436,14 @@ const quizLayout = (config: QuizLayoutConfig) => { this.isVerifying = true; try { - const response = await window.TutorCore.api.wpPost<{ - success: boolean; - data: { + const response = await wpPost< + AjaxResponse<{ question_id: number; is_correct: boolean; correct_answer_ids: number[]; answer_explanation?: string; - }; - }>(endpoints.QUIZ_CHECK_ANSWER, { + }> + >(endpoints.QUIZ_CHECK_ANSWER, { attempt_id: this.attemptId, quiz_id: this.quizId, question_id: questionId, diff --git a/classes/Quiz.php b/classes/Quiz.php index dce6bc19bf..133d407397 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1285,7 +1285,7 @@ public function tutor_quiz_check_answer() { tutor_utils()->checking_nonce(); if ( ! is_user_logged_in() ) { - wp_send_json_error( array( 'message' => __( 'Please sign in to perform this operation', 'tutor' ) ), 401 ); + $this->json_response( __( 'Please sign in to perform this operation', 'tutor' ), null, HttpHelper::STATUS_UNAUTHORIZED ); } $user_id = get_current_user_id(); @@ -1295,18 +1295,18 @@ public function tutor_quiz_check_answer() { $attempt = self::validate_attempt( $attempt_id, $user_id ); if ( ! $attempt ) { - wp_send_json_error( array( 'message' => __( 'Operation not allowed, attempt not found or permission denied', 'tutor' ) ), 403 ); + $this->json_response( __( 'Operation not allowed, attempt not found or permission denied', 'tutor' ), null, HttpHelper::STATUS_FORBIDDEN ); } if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status || QuizModel::ATTEMPT_ENDED === $attempt->attempt_status ) { - wp_send_json_error( array( 'message' => __( 'Attempt has ended or timed out', 'tutor' ) ), 400 ); + $this->json_response( __( 'Attempt has ended or timed out', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); } $effective_quiz_id = $quiz_id ? $quiz_id : (int) $attempt->quiz_id; $quiz_settings = tutor_utils()->get_quiz_option( $effective_quiz_id ); $enable_answer_reveal = '1' === (string) ( $quiz_settings['enable_answer_reveal'] ?? '0' ); if ( ! $enable_answer_reveal ) { - wp_send_json_error( array( 'message' => __( 'Answer reveal is disabled for this quiz', 'tutor' ) ), 403 ); + $this->json_response( __( 'Answer reveal is disabled for this quiz', 'tutor' ), null, HttpHelper::STATUS_FORBIDDEN ); } global $wpdb; @@ -1319,7 +1319,7 @@ public function tutor_quiz_check_answer() { ); if ( ! $question ) { - wp_send_json_error( array( 'message' => __( 'Question not found or does not belong to this quiz', 'tutor' ) ), 404 ); + $this->json_response( __( 'Question not found or does not belong to this quiz', 'tutor' ), null, HttpHelper::STATUS_NOT_FOUND ); } $answers = QuizModel::get_answers_by_quiz_question( $question_id ); @@ -1352,13 +1352,15 @@ public function tutor_quiz_check_answer() { $explanation = apply_filters( 'tutor_quiz_question_answer_explanation', '', $question, $attempt ); - wp_send_json_success( + $this->json_response( + '', array( 'question_id' => $question_id, 'is_correct' => $is_correct, 'correct_answer_ids' => $correct_answer_ids, 'answer_explanation' => $explanation, - ) + ), + HttpHelper::STATUS_OK ); } From b2485d4a8684b1e6e1bcf7e77b627aad24aa8ff9 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 5 Oct 2026 16:32:46 +0600 Subject: [PATCH 058/218] Unnecessary authentication check removed --- classes/Quiz.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 133d407397..87acabe6c5 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1284,10 +1284,6 @@ public function tutor_quiz_timeout() { public function tutor_quiz_check_answer() { tutor_utils()->checking_nonce(); - if ( ! is_user_logged_in() ) { - $this->json_response( __( 'Please sign in to perform this operation', 'tutor' ), null, HttpHelper::STATUS_UNAUTHORIZED ); - } - $user_id = get_current_user_id(); $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); $question_id = Input::post( 'question_id', 0, Input::TYPE_INT ); From a11e15224849efb483f2fdfedfe19f43ad02ef7c Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 5 Oct 2026 16:57:55 +0600 Subject: [PATCH 059/218] Fix quiz attempt JS escaping Safely encode the Alpine.js configuration used by the quiz attempt form by switching the `x-data` attribute to a double-quoted string and escaping embedded values with `esc_js`, `esc_attr`, and `tutor_json_encode`. This prevents malformed JavaScript when quiz IDs, form IDs, layout names, and default values contain quotes or special characters. --- templates/learning-area/quiz/attempt.php | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index 8f4b1c1a83..801aeb2a55 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -103,31 +103,31 @@ function ( $question ) { id="" class="tutor-quiz tutor-quiz-submission" data-question-layout-view="" - x-data='(() => { + x-data="(() => { const form = tutorForm({ - id: "", - mode: "onSubmit", - defaultValues: , + id: '', + mode: 'onSubmit', + defaultValues: , }); const layout = tutorQuizLayout({ - layout: "", - formId: "", - attemptId: "attempt_id ); ?>", - quizId: quiz_id ); ?>, - totalQuestions: , + layout: '', + formId: '', + attemptId: attempt_id; ?>, + quizId: quiz_id; ?>, + totalQuestions: , enableAnswerReveal: , - revealWaitMs: , + revealWaitMs: , }); const submission = tutorQuizSubmission({ - formId: "", - attemptId: "attempt_id ); ?>", - quizId: quiz_id ); ?>, - abandonModalId: "", - submittedModalId: "", - timeoutModalId: "", - totalQuestions: , + formId: '', + attemptId: attempt_id; ?>, + quizId: quiz_id; ?>, + abandonModalId: '', + submittedModalId: '', + timeoutModalId: '', + totalQuestions: , beforeSubmit() { return layout.revealOnSubmit.call(this); }, @@ -148,7 +148,7 @@ class="tutor-quiz tutor-quiz-submission" submission.destroy?.call(this); }, }; - })()' + })()" x-bind="getFormBindings()" @submit.prevent="handleSubmit( (data) => handleQuizSubmit(data), From 064229318f5b77fb093dd496d5a6c929dd302a2a Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Tue, 6 Oct 2026 11:14:49 +0600 Subject: [PATCH 060/218] [Security] GDPR security issue fixes --- GDPR/Controllers/UserConsent.php | 26 ++++++++++++++++--- .../admin-dashboard/segments/consent-logs.js | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/GDPR/Controllers/UserConsent.php b/GDPR/Controllers/UserConsent.php index 8a9505080e..afab7d6a65 100644 --- a/GDPR/Controllers/UserConsent.php +++ b/GDPR/Controllers/UserConsent.php @@ -33,7 +33,7 @@ class UserConsent extends BaseController { * * @since 4.0.0 * - * @var UserContents + * @var UserConsents */ private $model; @@ -110,7 +110,27 @@ public function render_consent_logs_column( $value, $column_name, $user_id ) { return $value; } - $value = ''; + $user_name = tutor_json_encode( $user->display_name ) ?? ''; + $user_joined = $user->user_registered ?? ''; + $user_email = $user->user_email ?? ''; + $user_login = $user->user_login ?? ''; + $avatar_src = get_avatar_url( $user_id, array( 'size' => 40 ) ); + + $value = ''; return $value; } @@ -387,7 +407,7 @@ public function render_consent_logs_button( $user_data ): void { return; } - $user_name = $user_data->display_name ?? ''; + $user_name = tutor_json_encode( $user_data->display_name ) ?? ''; $user_joined = $user_data->user_registered ?? ''; $user_email = $user_data->user_email ?? ''; $user_login = $user_data->user_login ?? ''; diff --git a/assets/src/js/admin-dashboard/segments/consent-logs.js b/assets/src/js/admin-dashboard/segments/consent-logs.js index 375afd5d0b..18ee03bd5c 100644 --- a/assets/src/js/admin-dashboard/segments/consent-logs.js +++ b/assets/src/js/admin-dashboard/segments/consent-logs.js @@ -116,7 +116,7 @@ const fetchAndRender = (userId, userName, userJoined, avatarSrc, userEmail, user From 52e0e2096f73717143f71d526840182f7780ce2c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 6 Oct 2026 12:19:12 +0600 Subject: [PATCH 061/218] Improve error handling in RestAuth class - Enhanced the error logging mechanism in the `RestAuth` class to log detailed information when API permission updates fail. - Added a user-friendly error message for clients while keeping the server-side error details secure. - This change aims to improve debugging and maintain security by not exposing sensitive error information to users. --- restapi/RestAuth.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 196bb655c1..abd9044c80 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -587,7 +587,16 @@ public static function update_api_permission() { wp_send_json_success( $response ); } catch ( \Throwable $th ) { - wp_send_json_error( $th->getMessage() ); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional server-side logging; do not expose to clients. + error_log( + sprintf( + 'Tutor REST API permission update failed: %s in %s at line %d', + $th->getMessage(), + $th->getFile(), + $th->getLine() + ) + ); + wp_send_json_error( __( 'API permission update failed, please try again.', 'tutor' ) ); } } From 4c89cf7729e8ea95c3f72909853a3ad34532b48e Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Tue, 6 Oct 2026 12:33:36 +0600 Subject: [PATCH 062/218] Fix: Added csv injection sanitization method --- GDPR/Controllers/UserConsent.php | 78 ++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/GDPR/Controllers/UserConsent.php b/GDPR/Controllers/UserConsent.php index afab7d6a65..5819da48f9 100644 --- a/GDPR/Controllers/UserConsent.php +++ b/GDPR/Controllers/UserConsent.php @@ -110,10 +110,10 @@ public function render_consent_logs_column( $value, $column_name, $user_id ) { return $value; } - $user_name = tutor_json_encode( $user->display_name ) ?? ''; - $user_joined = $user->user_registered ?? ''; - $user_email = $user->user_email ?? ''; - $user_login = $user->user_login ?? ''; + $user_name = tutor_json_encode( $this->sanitize_csv_field( $user->display_name ) ) ?? ''; + $user_joined = $this->sanitize_csv_field( $user->user_registered ) ?? ''; + $user_email = $this->sanitize_csv_field( $user->user_email ) ?? ''; + $user_login = $this->sanitize_csv_field( $user->user_login ) ?? ''; $avatar_src = get_avatar_url( $user_id, array( 'size' => 40 ) ); $value = ' From 9070afb1769075374edc3a0482c22aeac838fb1f Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Tue, 6 Oct 2026 16:59:06 +0600 Subject: [PATCH 068/218] fix(quiz): Allow skipping reveal wait timer on user navigation in legacy mode - Re-enable Next and Submit buttons once answer verification response is rendered - Clear reveal timeout when clicking Next, Previous, or Pagination items - Allow students to advance immediately without waiting for full reveal duration --- assets/src/js/front/course/_spotlight-quiz.js | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index f533e5ed1c..050f84430f 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -18,6 +18,15 @@ window.jQuery(document).ready($ => { } + var revealTimeoutId = null; + + function clearRevealTimeout() { + if (revealTimeoutId !== null) { + clearTimeout(revealTimeoutId); + revealTimeoutId = null; + } + } + function get_reveal_wait_time() { const quizLevelWait = Number(quiz_options.answers_reveal_duration || 0); if (quizLevelWait > 0) { @@ -211,6 +220,7 @@ window.jQuery(document).ready($ => { let current_question = parseInt($(this).closest('[data-question_index]').data('question_index')); // Show previous quiz if press previous button if ($(this).hasClass('tutor-quiz-answer-previous-btn')) { + clearRevealTimeout(); var $prev = $(this).closest('.quiz-attempt-single-question').hide().prev(); $prev.show(); if ($prev.attr('data-revealed') === '1') { @@ -238,6 +248,7 @@ window.jQuery(document).ready($ => { } var moveToNext = function () { + clearRevealTimeout(); if (next_question_id) { var $nextQuestion = $(next_question_id); if ($nextQuestion && $nextQuestion.length) { @@ -299,8 +310,10 @@ window.jQuery(document).ready($ => { } }, complete: function () { - setTimeout(function () { - $that.prop('disabled', false); + $that.prop('disabled', false); + clearRevealTimeout(); + revealTimeoutId = setTimeout(function () { + revealTimeoutId = null; moveToNext(); }, get_reveal_wait_time()); }, @@ -313,6 +326,7 @@ window.jQuery(document).ready($ => { $(document).on('click', '.tutor-quiz-question-paginate-item', function (e) { e.preventDefault(); + clearRevealTimeout(); var $that = $(this); var $question = $($that.attr('href')); $('.quiz-attempt-single-question').hide(); @@ -324,6 +338,11 @@ window.jQuery(document).ready($ => { //Active Class $('.tutor-quiz-question-paginate-item').removeClass('active'); $that.addClass('active'); + + var question_index = parseInt($question.data('question_index'), 10); + if (!isNaN(question_index)) { + $('.tutor-quiz-question-counter>span:first-child').text(question_index); + } }); /** @@ -420,6 +439,7 @@ window.jQuery(document).ready($ => { revealModeSupportedQuestions.includes(lastQuestionType) ) { if ($lastQuestion.attr('data-revealed') === '1') { + clearRevealTimeout(); $btn.prop('disabled', true).addClass('is-loading'); $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); document.getElementById('tutor-answering-quiz').submit(); @@ -435,6 +455,7 @@ window.jQuery(document).ready($ => { }); if (!checkedAnswers.length) { + clearRevealTimeout(); $btn.addClass('is-loading'); $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); document.getElementById('tutor-answering-quiz').submit(); @@ -460,8 +481,11 @@ window.jQuery(document).ready($ => { } }, complete: function () { - setTimeout(function () { - $btn.addClass('is-loading'); + $btn.prop('disabled', false); + clearRevealTimeout(); + revealTimeoutId = setTimeout(function () { + revealTimeoutId = null; + $btn.prop('disabled', true).addClass('is-loading'); $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); document.getElementById('tutor-answering-quiz').submit(); }, get_reveal_wait_time()); @@ -499,6 +523,7 @@ window.jQuery(document).ready($ => { id: 'leave', class: 'tutor-btn tutor-btn-outline-primary', callback: function () { + clearRevealTimeout(); $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); var formData = $('form#tutor-answering-quiz').serialize() + '&action=' + 'tutor_quiz_abandon'; $.ajax({ From 7b5ae91ba61da314816c7cd751e39b4c13892e0f Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Tue, 6 Oct 2026 17:07:33 +0600 Subject: [PATCH 069/218] fix(quiz): Add loading spinner to buttons during answer verification in legacy mode - Add is-loading class when triggering tutor_quiz_check_answer AJAX - Remove is-loading class once feedback is rendered --- assets/src/js/front/course/_spotlight-quiz.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index 050f84430f..4a0c0f6172 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -291,7 +291,7 @@ window.jQuery(document).ready($ => { return; } - $that.prop('disabled', true); + $that.prop('disabled', true).addClass('is-loading'); $.ajax({ url: window._tutorobject.ajaxurl, @@ -310,7 +310,7 @@ window.jQuery(document).ready($ => { } }, complete: function () { - $that.prop('disabled', false); + $that.prop('disabled', false).removeClass('is-loading'); clearRevealTimeout(); revealTimeoutId = setTimeout(function () { revealTimeoutId = null; @@ -462,7 +462,7 @@ window.jQuery(document).ready($ => { return; } - $btn.prop('disabled', true); + $btn.prop('disabled', true).addClass('is-loading'); $.ajax({ url: window._tutorobject.ajaxurl, @@ -481,7 +481,7 @@ window.jQuery(document).ready($ => { } }, complete: function () { - $btn.prop('disabled', false); + $btn.prop('disabled', false).removeClass('is-loading'); clearRevealTimeout(); revealTimeoutId = setTimeout(function () { revealTimeoutId = null; From 4be0f14f2ee2de328a29859ebe27d0300af8664b Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Tue, 6 Oct 2026 17:46:49 +0600 Subject: [PATCH 070/218] Resolve warning message --- ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php index 70fef9bd04..a4d8f30e76 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php @@ -190,10 +190,6 @@ public static function respond_to_event(): void { http_response_code( 200 ); echo 'OK'; - - if ( function_exists( 'fastcgi_finish_request' ) ) { - fastcgi_finish_request(); - } } /** From cf928b2dd26fafb0c0d8ac81c6b7016fadf9c4e1 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Wed, 7 Oct 2026 11:34:20 +0600 Subject: [PATCH 071/218] refactor(quiz): Remove empty div --- .../components/curriculum/QuizSettings.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 5286a62f97..8793debfb1 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -107,9 +107,15 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { const questionsCount = questions.length; const hasOpenEndedQuestions = questions.some((question) => question.question_type === 'open_ended'); const hasShortAnswerQuestions = questions.some((question) => question.question_type === 'short_answer'); + const hasCharacterLimitQuestions = hasOpenEndedQuestions || hasShortAnswerQuestions; const hasAttemptsLimit = form.watch('quiz_option.limit_attempts_allowed'); const showPassRequired = isAddonEnabled(Addons.CONTENT_DRIP) && contentDripType === 'unlock_sequentially' && hasAttemptsLimit; + const showContentDripSettings = + isAddonEnabled(Addons.CONTENT_DRIP) && + contentType !== 'tutor_h5p_quiz' && + Boolean(contentDripType) && + contentDripType !== 'unlock_sequentially'; const hasQuestionLimit = form.watch('quiz_option.limit_questions_to_answer'); const hasTimeLimit = form.watch('quiz_option.enable_time_limit'); const questionsOrder = form.watch('quiz_option.questions_order'); @@ -696,13 +702,9 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
- +
- +
{__('Character Limits', 'tutor')}
@@ -756,7 +758,7 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
- +
From f23af0beb4b8385c26b8ae44b4b6c3fd06243fcc Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Wed, 7 Oct 2026 12:09:47 +0600 Subject: [PATCH 072/218] Fix quiz submit timing and validation This updates the quiz flow to clear pending submit timers while navigating, prevent empty-answer submissions, and avoid blocking navigation while a submit is pending. It also aligns the UI state handling for verification and submit buttons, and fixes focus/hover styling so disabled quiz options no longer appear active. --- .../js/frontend/learning-area/quiz/layout.ts | 18 ++++++++++ .../frontend/learning-area/quiz/submission.ts | 34 ++++++++++++++++--- .../frontend/components/_quiz-question.scss | 4 +-- templates/learning-area/quiz/attempt.php | 14 ++++---- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/assets/src/js/frontend/learning-area/quiz/layout.ts b/assets/src/js/frontend/learning-area/quiz/layout.ts index 63dee339eb..932aad5712 100644 --- a/assets/src/js/frontend/learning-area/quiz/layout.ts +++ b/assets/src/js/frontend/learning-area/quiz/layout.ts @@ -15,6 +15,14 @@ export interface QuizLayoutConfig { startIndex?: number; } +/** + * Provided by `tutorQuizSubmission` (`submission.ts`), merged into the + * same Alpine component via `attempt.php`. + */ +interface QuizSubmissionContext { + clearSubmitTimeout?: () => void; +} + const quizLayout = (config: QuizLayoutConfig) => { const { form } = window.TutorCore; const { tutorConfig } = window.TutorCore.config; @@ -274,6 +282,7 @@ const quizLayout = (config: QuizLayoutConfig) => { } if (this.currentIndex > 1) { this.clearRevealTimeout(); + (this as unknown as QuizSubmissionContext).clearSubmitTimeout?.(); this.markCurrentAsSkipped(); this.runWithViewTransition(() => { this.currentIndex -= 1; @@ -348,6 +357,7 @@ const quizLayout = (config: QuizLayoutConfig) => { return; } this.clearRevealTimeout(); + (this as unknown as QuizSubmissionContext).clearSubmitTimeout?.(); this.markCurrentAsSkipped(); this.runWithViewTransition( () => { @@ -436,6 +446,10 @@ const quizLayout = (config: QuizLayoutConfig) => { ); const answers = checkedInputs.map((input) => Number(input.value)).filter((val) => !Number.isNaN(val)); + if (!answers.length) { + return false; + } + this.isVerifying = true; try { @@ -483,6 +497,10 @@ const quizLayout = (config: QuizLayoutConfig) => { return false; } + if (!this.isQuestionAttempted(this.currentIndex)) { + return false; + } + const revealed = await this.verifyAndRevealQuestion(wrapper, this.currentIndex); if (revealed) { return this.getRevealWaitTime(); diff --git a/assets/src/js/frontend/learning-area/quiz/submission.ts b/assets/src/js/frontend/learning-area/quiz/submission.ts index 93d9e99959..471f6f4e06 100644 --- a/assets/src/js/frontend/learning-area/quiz/submission.ts +++ b/assets/src/js/frontend/learning-area/quiz/submission.ts @@ -36,6 +36,8 @@ const quizSubmission = (config: QuizSubmissionConfig) => { hasTimedOut: false, isRevealSubmitting: false, + isWaitingSubmit: false, + submitTimeoutId: null as number | null, beforeUnloadTriggered: false, isAbandoningNavigation: false, skipBeforeUnload: false, @@ -123,8 +125,28 @@ const quizSubmission = (config: QuizSubmissionConfig) => { }); }, + clearSubmitTimeout() { + if (this.submitTimeoutId !== null) { + window.clearTimeout(this.submitTimeoutId); + this.submitTimeoutId = null; + } + this.isWaitingSubmit = false; + this.isRevealSubmitting = false; + }, + async handleQuizSubmit(data: Record) { - if (this.isRevealSubmitting || this.submitQuizMutation?.isPending) { + if (this.submitQuizMutation?.isPending) { + return; + } + + if (this.isWaitingSubmit) { + this.clearSubmitTimeout(); + const payload = this.buildSubmitPayload(data); + this.submitQuizMutation?.mutate(payload); + return; + } + + if (this.isRevealSubmitting) { return; } @@ -136,9 +158,12 @@ const quizSubmission = (config: QuizSubmissionConfig) => { try { const delay = await config.beforeSubmit.call(this); if (typeof delay === 'number' && delay > 0) { - window.setTimeout(() => { + this.isRevealSubmitting = false; + this.isWaitingSubmit = true; + this.submitTimeoutId = window.setTimeout(() => { + this.submitTimeoutId = null; + this.isWaitingSubmit = false; this.submitQuizMutation?.mutate(payload); - this.isRevealSubmitting = false; }, delay); return; } @@ -238,7 +263,7 @@ const quizSubmission = (config: QuizSubmissionConfig) => { if (this.isAbandoningNavigation) { return false; } - if (this.hasTimedOut || this.isRevealSubmitting) { + if (this.hasTimedOut || this.isRevealSubmitting || this.isWaitingSubmit) { return false; } if (this.submitQuizMutation?.isPending || this.abandonQuizMutation?.isPending) { @@ -443,6 +468,7 @@ const quizSubmission = (config: QuizSubmissionConfig) => { }, destroy() { + this.clearSubmitTimeout(); if (this.beforeUnloadHandler) { window.removeEventListener('beforeunload', this.beforeUnloadHandler); } diff --git a/assets/src/scss/frontend/components/_quiz-question.scss b/assets/src/scss/frontend/components/_quiz-question.scss index b6a135f5c8..e744fb8566 100644 --- a/assets/src/scss/frontend/components/_quiz-question.scss +++ b/assets/src/scss/frontend/components/_quiz-question.scss @@ -225,7 +225,7 @@ &:focus, &:focus-within { - &:not(:has(:checked)) { + &:not(:has(:checked)):not(:has(:disabled)) { border-color: $tutor-border-hover; background-color: $tutor-quiz-hover; } @@ -281,7 +281,7 @@ } } - &:focus-within:has(:checked) { + &:focus-within:has(:checked):not(:has(:disabled)) { font-weight: $tutor-font-weight-medium; border-color: $tutor-border-brand; background-color: $tutor-surface-brand-secondary; diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index ca96707bf9..d672f62bb4 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -312,7 +312,7 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->variant( Variant::LINK_GRAY ) ->attr( 'type', 'button' ) - ->attr( ':disabled', 'isRevealSubmitting || isRevealing || isVerifying' ) + ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending' ) ->attr( 'x-show', 'canSkip(currentIndex) && revealFooterState === ""' ) ->attr( '@click', 'goNext({ skipValidation: true })' ) ->attr( 'class', 'tutor-quiz-skip-btn' ) @@ -328,7 +328,7 @@ class="tutor-quiz-footer-feedback-text" ->icon( Icon::ARROW_LEFT_2, 'left', 20 ) ->flip_rtl() ->attr( 'type', 'button' ) - ->attr( ':disabled', 'isRevealSubmitting || isVerifying' ) + ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending' ) ->attr( '@click', 'goPrev()' ) ->attr( 'x-show', $show_previous_button ? 'currentIndex > 1' : 'false' ) ->attr( 'class', 'tutor-quiz-answer-previous-btn' ) @@ -338,7 +338,7 @@ class="tutor-quiz-footer-feedback-text" ->label( __( 'Next', 'tutor' ) ) ->size( Size::LARGE ) ->attr( 'type', 'button' ) - ->attr( ':disabled', 'isRevealSubmitting || isVerifying || shouldDisableNextButton()' ) + ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending || shouldDisableNextButton()' ) ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying }' ) ->attr( '@click', 'goNext()' ) ->attr( 'x-show', 'currentIndex < totalQuestions' ) @@ -350,8 +350,8 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->attr( 'type', 'submit' ) ->attr( 'x-show', 'currentIndex === totalQuestions' ) - ->attr( ':disabled', 'isRevealSubmitting || submitQuizMutation?.isPending' ) - ->attr( ':class', '{ \'tutor-btn-loading\': submitQuizMutation?.isPending }' ) + ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending' ) + ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying || submitQuizMutation?.isPending }' ) ->attr( 'class', 'tutor-quiz-submit-btn' ) ->render(); ?> @@ -366,8 +366,8 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->attr( 'form', $form_id ) ->attr( 'type', 'submit' ) - ->attr( ':disabled', 'isRevealSubmitting || submitQuizMutation?.isPending' ) - ->attr( ':class', '{ \'tutor-btn-loading\': submitQuizMutation?.isPending }' ) + ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending' ) + ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying || submitQuizMutation?.isPending }' ) ->attr( 'style', 'display: block; margin: 0 auto; min-width: 290px;' ) ->render(); ?> From 917e0cfcb9814f33998ef66a29233c2538ef1f1b Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Wed, 7 Oct 2026 12:50:40 +0600 Subject: [PATCH 073/218] Again added env parameter to prepareMerchantMetadata() and made the function name CamelCase. --- .../PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php | 5 +---- ecommerce/PaymentGateways/Utils.php | 6 ++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index c936405e09..a977fc1eda 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -120,10 +120,7 @@ public function prepareData( $data ): array { $type = $data->type ?? 'one-time'; $items = 'one-time' === $type ? Helper::getItems( $data ) : null; $amount = 'one-time' === $type ? Helper::createAmountData( $data ) : Helper::createAmountForRecurring( $data ); - $tutor_metadata = array_merge( - Utils::prepare_merchant_metadata( $data->order_user_id ), - array( 'env' => $this->config->get( 'mode' ) ) - ); + $tutor_metadata = Utils::prepareMerchantMetadata( $data->order_user_id, $this->config->get( 'mode' ) ); $description = implode( ',', array_map( diff --git a/ecommerce/PaymentGateways/Utils.php b/ecommerce/PaymentGateways/Utils.php index 68adc67ea7..6548ae63d4 100644 --- a/ecommerce/PaymentGateways/Utils.php +++ b/ecommerce/PaymentGateways/Utils.php @@ -220,17 +220,19 @@ public static function updateWebhookUrl( $config ): string { * * @since 4.1.2 * - * @param int $order_user_id The ID of the user who placed the order. + * @param int $order_user_id The ID of the user who placed the order. + * @param string $gateway_environment Payment environment mode * * @return array The metadata as string key/value pairs. */ - public static function prepare_merchant_metadata( $order_user_id ): array { + public static function prepareMerchantMetadata( $order_user_id, $gateway_environment ): array { $user = $order_user_id ? get_userdata( $order_user_id ) : false; return array( 'tutor_version' => defined( 'TUTOR_VERSION' ) ? TUTOR_VERSION : '', 'wp_user' => $user ? "{$user->ID} | {$user->user_email}" : '', 'site_url' => get_site_url(), + 'env' => $gateway_environment, ); } } From 4f1191478e2db6667c184e233903b631218f4f22 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Wed, 7 Oct 2026 12:52:16 +0600 Subject: [PATCH 074/218] style(quiz): Update inline form styles --- .../course-builder/components/curriculum/QuizSettings.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 8793debfb1..e8fd2e13fb 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -628,7 +628,7 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
-
+
Date: Wed, 7 Oct 2026 15:36:12 +0600 Subject: [PATCH 075/218] feat(quiz): add countdown fill animation to navigation buttons during reveal wait --- assets/src/js/front/course/_spotlight-quiz.js | 5 +++ .../js/frontend/learning-area/quiz/layout.ts | 3 +- .../src/scss/front/course-spotlight/quiz.scss | 31 +++++++++++++++++ .../scss/frontend/learning-area/_quiz.scss | 33 +++++++++++++++++++ templates/learning-area/quiz/attempt.php | 6 ++-- 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index 4a0c0f6172..bf8e1986ec 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -25,6 +25,7 @@ window.jQuery(document).ready($ => { clearTimeout(revealTimeoutId); revealTimeoutId = null; } + $('.tutor-quiz-btn-countdown').removeClass('tutor-quiz-btn-countdown'); } function get_reveal_wait_time() { @@ -312,8 +313,10 @@ window.jQuery(document).ready($ => { complete: function () { $that.prop('disabled', false).removeClass('is-loading'); clearRevealTimeout(); + $that.addClass('tutor-quiz-btn-countdown').css('--reveal-wait-duration', get_reveal_wait_time() + 'ms'); revealTimeoutId = setTimeout(function () { revealTimeoutId = null; + $that.removeClass('tutor-quiz-btn-countdown'); moveToNext(); }, get_reveal_wait_time()); }, @@ -483,8 +486,10 @@ window.jQuery(document).ready($ => { complete: function () { $btn.prop('disabled', false).removeClass('is-loading'); clearRevealTimeout(); + $btn.addClass('tutor-quiz-btn-countdown').css('--reveal-wait-duration', get_reveal_wait_time() + 'ms'); revealTimeoutId = setTimeout(function () { revealTimeoutId = null; + $btn.removeClass('tutor-quiz-btn-countdown'); $btn.prop('disabled', true).addClass('is-loading'); $('#tutor-answering-quiz').find('input, select, textarea').prop('disabled', false); document.getElementById('tutor-answering-quiz').submit(); diff --git a/assets/src/js/frontend/learning-area/quiz/layout.ts b/assets/src/js/frontend/learning-area/quiz/layout.ts index 932aad5712..35224a52e4 100644 --- a/assets/src/js/frontend/learning-area/quiz/layout.ts +++ b/assets/src/js/frontend/learning-area/quiz/layout.ts @@ -58,6 +58,7 @@ const quizLayout = (config: QuizLayoutConfig) => { init() { container = (this.$root ?? this.$el)?.querySelector(QUIZ_LAYOUT_SELECTORS.QUESTIONS_CONTAINER); + (this.$root ?? this.$el)?.style.setProperty('--reveal-wait-duration', `${this.getRevealWaitTime()}ms`); // Keyboard vs Mouse Navigation helper for focus styles handleFirstTab = (e: KeyboardEvent) => { @@ -328,9 +329,9 @@ const quizLayout = (config: QuizLayoutConfig) => { return; } - this.isRevealing = true; const verified = await this.verifyAndRevealQuestion(wrapper, this.currentIndex); if (verified) { + this.isRevealing = true; const wait = this.getRevealWaitTime(); this.revealTimeoutId = window.setTimeout(() => { this.isRevealing = false; diff --git a/assets/src/scss/front/course-spotlight/quiz.scss b/assets/src/scss/front/course-spotlight/quiz.scss index 182db65c93..258e8e2285 100644 --- a/assets/src/scss/front/course-spotlight/quiz.scss +++ b/assets/src/scss/front/course-spotlight/quiz.scss @@ -426,3 +426,34 @@ line-height: 26px; color: #f44337; } + +@keyframes tutor-quiz-countdown-fill { + 0% { + background-size: 0% 100%; + } + 100% { + background-size: 100% 100%; + } +} + +.tutor-quiz-btn-countdown { + background-image: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)); + background-repeat: no-repeat; + background-position: left top; + animation: tutor-quiz-countdown-fill var(--reveal-wait-duration, 2000ms) linear forwards; + + [dir='rtl'] &, + .rtl & { + background-position: right top; + } + + [data-tutor-motion='reduce'] & { + animation: none; + background-image: none; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + background-image: none; + } +} diff --git a/assets/src/scss/frontend/learning-area/_quiz.scss b/assets/src/scss/frontend/learning-area/_quiz.scss index eae1d08bc2..45c2c47e1d 100644 --- a/assets/src/scss/frontend/learning-area/_quiz.scss +++ b/assets/src/scss/frontend/learning-area/_quiz.scss @@ -877,6 +877,30 @@ $tutor-quiz-draw-pin-chrome: '#{$tutor-spacing-5} + 2.5rem + #{$tutor-spacing-8} } } + &-btn-countdown, + &.tutor-quiz-btn-countdown, + .tutor-quiz-btn-countdown { + background-image: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)); + background-repeat: no-repeat; + background-position: left top; + animation: tutor-quiz-countdown-fill var(--reveal-wait-duration, 2000ms) linear forwards; + + [dir='rtl'] &, + .rtl & { + background-position: right top; + } + + [data-tutor-motion='reduce'] & { + animation: none; + background-image: none; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + background-image: none; + } + } + &-question { &-option { cursor: pointer; @@ -1493,3 +1517,12 @@ body:has(#wpadminbar) .tutor-learning-area.tutor-has-site-shell { position: absolute; } } + +@keyframes tutor-quiz-countdown-fill { + 0% { + background-size: 0% 100%; + } + 100% { + background-size: 100% 100%; + } +} diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index d672f62bb4..309ee5d51e 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -339,7 +339,7 @@ class="tutor-quiz-footer-feedback-text" ->size( Size::LARGE ) ->attr( 'type', 'button' ) ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending || shouldDisableNextButton()' ) - ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying }' ) + ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying, \'tutor-quiz-btn-countdown\': isRevealing }' ) ->attr( '@click', 'goNext()' ) ->attr( 'x-show', 'currentIndex < totalQuestions' ) ->attr( 'class', 'tutor-quiz-answer-next-btn' ) @@ -351,7 +351,7 @@ class="tutor-quiz-footer-feedback-text" ->attr( 'type', 'submit' ) ->attr( 'x-show', 'currentIndex === totalQuestions' ) ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending' ) - ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying || submitQuizMutation?.isPending }' ) + ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying || submitQuizMutation?.isPending, \'tutor-quiz-btn-countdown\': isWaitingSubmit }' ) ->attr( 'class', 'tutor-quiz-submit-btn' ) ->render(); ?> @@ -367,7 +367,7 @@ class="tutor-quiz-footer-feedback-text" ->attr( 'form', $form_id ) ->attr( 'type', 'submit' ) ->attr( ':disabled', 'isVerifying || submitQuizMutation?.isPending' ) - ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying || submitQuizMutation?.isPending }' ) + ->attr( ':class', '{ \'tutor-btn-loading\': isVerifying || submitQuizMutation?.isPending, \'tutor-quiz-btn-countdown\': isWaitingSubmit }' ) ->attr( 'style', 'display: block; margin: 0 auto; min-width: 290px;' ) ->render(); ?> From 31bff5a1d7768b5c7b82d103fca72d118b607aa1 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Wed, 7 Oct 2026 15:40:57 +0600 Subject: [PATCH 076/218] Unit test added for HttpHelper::class --- tests/phpunit/HttpHelperTest.php | 298 +++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 tests/phpunit/HttpHelperTest.php diff --git a/tests/phpunit/HttpHelperTest.php b/tests/phpunit/HttpHelperTest.php new file mode 100644 index 0000000000..37a6c642ba --- /dev/null +++ b/tests/phpunit/HttpHelperTest.php @@ -0,0 +1,298 @@ + + * @link https://themeum.com + * @since 4.1.2 + */ + +namespace TutorTest; + +use Tutor\Helpers\HttpHelper; +use WP_Error; + +/** + * HttpHelperTest Class + * + * Requests are intercepted with the `pre_http_request` filter, so no real HTTP call is made. + * + * @since 4.1.2 + */ +class HttpHelperTest extends \WP_UnitTestCase { + + /** + * Test request URL. + * + * @var string + */ + const URL = 'https://api.example.com/v1/items'; + + /** + * Captured request, with `url` and `args` keys. + * + * @var array|null + */ + private $request; + + /** + * Fake response returned by the `pre_http_request` filter. + * + * @var array|WP_Error + */ + private $response; + + /** + * Intercept HTTP requests before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + $this->request = null; + $this->response = $this->fake_response(); + + add_filter( 'pre_http_request', array( $this, 'intercept_request' ), 10, 3 ); + } + + /** + * Remove the request interceptor after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'intercept_request' ), 10 ); + + parent::tear_down(); + } + + /** + * Capture the request and short-circuit it with the fake response. + * + * @param false|array|WP_Error $preempt Default false. + * @param array $parsed_args Request arguments. + * @param string $url Request URL. + * + * @return array|WP_Error + */ + public function intercept_request( $preempt, $parsed_args, $url ) { + $this->request = array( + 'url' => $url, + 'args' => $parsed_args, + ); + + return $this->response; + } + + /** + * Build a response in the format wp_remote_request() returns. + * + * @param string $body Response body. + * @param int $code HTTP status code. + * @param array $headers Response headers. + * + * @return array + */ + private function fake_response( $body = '{"id":1,"name":"Tutor"}', $code = HttpHelper::STATUS_OK, $headers = array( 'content-type' => 'application/json' ) ) { + return array( + 'headers' => $headers, + 'body' => $body, + 'response' => array( + 'code' => $code, + 'message' => get_status_header_desc( $code ), + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + /** + * GET request sends the method and headers without a body. + * + * @return void + */ + public function test_get_sends_method_and_headers() { + $headers = array( 'Authorization' => 'Bearer token' ); + + HttpHelper::get( self::URL, array(), $headers ); + + $this->assertSame( self::URL, $this->request['url'] ); + $this->assertSame( HttpHelper::METHOD_GET, $this->request['args']['method'] ); + $this->assertSame( $headers, $this->request['args']['headers'] ); + $this->assertNull( $this->request['args']['body'] ); + } + + /** + * GET request appends data to the URL as query parameters. + * + * @return void + */ + public function test_get_appends_data_as_query_params() { + HttpHelper::get( + self::URL, + array( + 'page' => 2, + 'per_page' => 10, + ) + ); + + $this->assertSame( self::URL . '?page=2&per_page=10', $this->request['url'] ); + } + + /** + * GET request keeps existing query parameters on the URL. + * + * @return void + */ + public function test_get_keeps_existing_query_params() { + HttpHelper::get( self::URL . '?status=active', array( 'page' => 2 ) ); + + $this->assertSame( self::URL . '?status=active&page=2', $this->request['url'] ); + } + + /** + * Data provider for the methods that send a request body. + * + * @return array + */ + public static function body_method_provider() { + return array( + 'post' => array( 'post', HttpHelper::METHOD_POST ), + 'put' => array( 'put', HttpHelper::METHOD_PUT ), + 'patch' => array( 'patch', HttpHelper::METHOD_PATCH ), + 'delete' => array( 'delete', HttpHelper::METHOD_DELETE ), + ); + } + + /** + * Body methods send the HTTP method, an array body and headers. + * + * @dataProvider body_method_provider + * + * @param string $helper_method HttpHelper method name. + * @param string $method Expected HTTP method. + * + * @return void + */ + public function test_body_methods_send_array_body( $helper_method, $method ) { + $data = array( 'name' => 'Tutor' ); + $headers = array( 'Accept' => 'application/json' ); + + HttpHelper::$helper_method( self::URL, $data, $headers ); + + $this->assertSame( self::URL, $this->request['url'] ); + $this->assertSame( $method, $this->request['args']['method'] ); + $this->assertSame( $data, $this->request['args']['body'] ); + $this->assertSame( $headers, $this->request['args']['headers'] ); + } + + /** + * Body methods send a string body (e.g. JSON) as-is. + * + * @dataProvider body_method_provider + * + * @param string $helper_method HttpHelper method name. + * @param string $method Expected HTTP method. + * + * @return void + */ + public function test_body_methods_send_string_body_as_is( $helper_method, $method ) { + $json = wp_json_encode( array( 'name' => 'Tutor' ) ); + + HttpHelper::$helper_method( self::URL, $json, array( 'Content-Type' => 'application/json' ) ); + + $this->assertSame( $method, $this->request['args']['method'] ); + $this->assertSame( $json, $this->request['args']['body'] ); + } + + /** + * Extra arguments are passed through to wp_remote_request(). + * + * @return void + */ + public function test_args_are_passed_through() { + HttpHelper::get( self::URL, array(), array(), array( 'timeout' => 30 ) ); + $this->assertSame( 30, $this->request['args']['timeout'] ); + + HttpHelper::post( self::URL, array(), array(), array( 'timeout' => 60 ) ); + $this->assertSame( 60, $this->request['args']['timeout'] ); + } + + /** + * Extra arguments override the defaults built from the other parameters. + * + * @return void + */ + public function test_args_override_defaults() { + HttpHelper::post( self::URL, array( 'a' => 1 ), array(), array( 'body' => 'override' ) ); + + $this->assertSame( 'override', $this->request['args']['body'] ); + } + + /** + * A successful response is parsed into body, JSON, headers and status code. + * + * @return void + */ + public function test_parses_successful_response() { + $response = HttpHelper::get( self::URL ); + + $this->assertInstanceOf( HttpHelper::class, $response ); + $this->assertFalse( $response->has_error() ); + $this->assertSame( HttpHelper::STATUS_OK, $response->get_status_code() ); + $this->assertSame( '{"id":1,"name":"Tutor"}', $response->get_body() ); + $this->assertSame( array( 'content-type' => 'application/json' ), $response->get_headers() ); + + $json = $response->get_json(); + $this->assertIsObject( $json ); + $this->assertSame( 1, $json->id ); + $this->assertSame( 'Tutor', $json->name ); + } + + /** + * Invalid JSON body makes get_json() return null. + * + * @return void + */ + public function test_get_json_returns_null_for_invalid_json() { + $this->response = $this->fake_response( 'not json', HttpHelper::STATUS_OK, array( 'content-type' => 'text/plain' ) ); + + $response = HttpHelper::get( self::URL ); + + $this->assertSame( 'not json', $response->get_body() ); + $this->assertNull( $response->get_json() ); + } + + /** + * A 4xx/5xx response is not a transport error; the status and body are kept. + * + * @return void + */ + public function test_error_status_is_not_a_transport_error() { + $this->response = $this->fake_response( '{"message":"Not found"}', HttpHelper::STATUS_NOT_FOUND ); + + $response = HttpHelper::delete( self::URL ); + + $this->assertFalse( $response->has_error() ); + $this->assertSame( HttpHelper::STATUS_NOT_FOUND, $response->get_status_code() ); + $this->assertSame( 'Not found', $response->get_json()->message ); + } + + /** + * A WP_Error from the HTTP API is reported as an error. + * + * @return void + */ + public function test_wp_error_is_reported() { + $this->response = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ); + + $response = HttpHelper::post( self::URL, array( 'a' => 1 ) ); + + $this->assertTrue( $response->has_error() ); + $this->assertSame( 'cURL error 28: Operation timed out', $response->get_error_message() ); + $this->assertNull( $response->get_status_code() ); + $this->assertNull( $response->get_body() ); + } +} From 4130af8a783ba71a316717591988029d1ddba65c Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Wed, 7 Oct 2026 15:46:06 +0600 Subject: [PATCH 077/218] Version updated --- ecommerce/PaymentGateways/Http.php | 6 ++--- .../Paypal/src/Core/Support/System.php | 2 +- .../src/Exceptions/HttpRequestException.php | 10 ++++----- .../Paypal/src/Payments/Paypal/Api.php | 4 ++-- .../Paypal/src/Payments/Paypal/Helper.php | 2 +- .../Paypal/src/Payments/Paypal/Paypal.php | 8 +++---- ecommerce/PaymentGateways/Utils.php | 22 +++++++++---------- helpers/HttpHelper.php | 12 +++++----- tests/phpunit/HttpHelperTest.php | 4 ++-- 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/ecommerce/PaymentGateways/Http.php b/ecommerce/PaymentGateways/Http.php index 1097d4ea51..ffd582f78a 100644 --- a/ecommerce/PaymentGateways/Http.php +++ b/ecommerce/PaymentGateways/Http.php @@ -5,7 +5,7 @@ * @package Tutor\Ecommerce * @author Themeum * @link https://themeum.com - * @since 4.1.2 + * @since 4.2.0 */ namespace Tutor\PaymentGateways; @@ -22,7 +22,7 @@ * Unlike HttpHelper, it throws HttpRequestException on transport errors and * 4xx/5xx responses so gateways can handle failures in one catch block. * - * @since 4.1.2 + * @since 4.2.0 */ class Http { /** @@ -36,7 +36,7 @@ class Http { * - options (array) Optional. `headers` (array), `body` (array|string) and `method`. * For GET, an array `body` is sent as query parameters. * - * @since 4.1.2 Moved from System::sendHttpRequest() and sends the request through HttpHelper instead of Guzzle. + * @since 4.2.0 Moved from System::sendHttpRequest() and sends the request through HttpHelper instead of Guzzle. * * @param array|object $request_data Request data with url, optional method, and options. * @param bool $return_raw_payload Whether to return the HttpHelper response diff --git a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index 4ef576195b..d564f0768f 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -7,7 +7,7 @@ * Core helpers for the PaymentHub library. * * @since 1.0.0 - * @since 4.1.2 HTTP, money, address and order data helpers moved to + * @since 4.2.0 HTTP, money, address and order data helpers moved to * \Tutor\PaymentGateways\Http and \Tutor\PaymentGateways\Utils. */ class System { diff --git a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php b/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php index ceb34045ce..86534551c4 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php +++ b/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php @@ -8,14 +8,14 @@ /** * Thrown when an HTTP request fails at the transport level or returns a 4xx/5xx status. * - * @since 4.1.2 + * @since 4.2.0 */ class HttpRequestException extends RuntimeException { /** * The response, or null when the request never received one. * - * @since 4.1.2 + * @since 4.2.0 * * @var HttpHelper|null */ @@ -24,7 +24,7 @@ class HttpRequestException extends RuntimeException { /** * Create a new exception instance. * - * @since 4.1.2 + * @since 4.2.0 * * @param string $message The exception message. * @param int $code The HTTP status code, or 0 for transport errors. @@ -40,7 +40,7 @@ public function __construct( string $message = '', int $code = 0, ?HttpHelper $r /** * Get the response, or null for transport errors. * - * @since 4.1.2 + * @since 4.2.0 * * @return HttpHelper|null */ @@ -51,7 +51,7 @@ public function get_response(): ?HttpHelper { /** * Determine if a response was received. * - * @since 4.1.2 + * @since 4.2.0 * * @return bool */ diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php index a4d8f30e76..67bdec3282 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Api.php @@ -196,7 +196,7 @@ public static function respond_to_event(): void { * Retrieve PayPal order details using the provided API URL. * * @since 3.9.0 - * @since 4.1.2 Catches HttpRequestException instead of Guzzle's RequestException. + * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. * * @param string $url The PayPal API endpoint URL for fetching order details. * @return object|null The API response object containing order details. @@ -259,7 +259,7 @@ public static function refund( $refund_url, $order_id, $data ) { * Retrieve PayPal vault details from a given URL. * * @since 3.9.0 - * @since 4.1.2 Catches HttpRequestException instead of Guzzle's RequestException. + * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. * * @param string $url The PayPal vault API URL to retrieve details. * diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php index d3803a5bd9..9159a7ea44 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php @@ -178,7 +178,7 @@ public static function getPaymentSourceForRecurring( &$returnData, $previous_pay * @param HttpRequestException $errorResponse The error response from the HTTP request. * @return string|null The formatted error message. * @since 3.0.0 - * @since 4.1.2 Accepts HttpRequestException instead of Guzzle's RequestException. + * @since 4.2.0 Accepts HttpRequestException instead of Guzzle's RequestException. */ public static function handleErrorResponse( $errorResponse ): ?string { $message = ''; diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index a977fc1eda..0b06ae2a17 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -167,7 +167,7 @@ public function prepareData( $data ): array { * * @throws ErrorException If there is an error retrieving the checkout URL or handling the response. * @since 3.0.0 - * @since 4.1.2 Catches HttpRequestException, logs the error and shows a generic message. + * @since 4.2.0 Catches HttpRequestException, logs the error and shows a generic message. */ public function createPayment() { try { @@ -199,7 +199,7 @@ public function createPayment() { * @return object Returns the processed order data or an error response. * @throws HttpRequestException If the request fails. * @since 3.0.0 - * @since 4.1.2 Catches HttpRequestException instead of Guzzle's RequestException. + * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. */ public function verifyAndCreateOrderData( object $payload ): object { try { @@ -278,7 +278,7 @@ private function setReturnData( $payloadStream ): object { * * @throws ErrorException If there is an error during the payment process or request handling. * @since 3.0.0 - * @since 4.1.2 Catches HttpRequestException, logs the error and shows a generic message. + * @since 4.2.0 Catches HttpRequestException, logs the error and shows a generic message. */ public function createRecurringPayment() { try { @@ -327,7 +327,7 @@ private function prepareDataForRefund( $data ): array { * * @throws ErrorException Throws an exception if an error occurs while making the HTTP request or processing the response. * @since 3.0.0 - * @since 4.1.2 Catches HttpRequestException instead of Guzzle's RequestException. + * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. */ public function createRefund() { diff --git a/ecommerce/PaymentGateways/Utils.php b/ecommerce/PaymentGateways/Utils.php index 6548ae63d4..5b21e3ceca 100644 --- a/ecommerce/PaymentGateways/Utils.php +++ b/ecommerce/PaymentGateways/Utils.php @@ -5,7 +5,7 @@ * @package Tutor\Ecommerce * @author Themeum * @link https://themeum.com - * @since 4.1.2 + * @since 4.2.0 */ namespace Tutor\PaymentGateways; @@ -24,14 +24,14 @@ * Most methods were moved from Ollyo\PaymentHub\Core\Support\System. The money * methods need brick/money, which GatewayBase loads from the PayPal vendor folder. * - * @since 4.1.2 + * @since 4.2.0 */ class Utils { /** * Create an object for the default order data. * * @since 1.0.0 - * @since 4.1.2 Moved from System. + * @since 4.2.0 Moved from System. * * @param string $type Order data type: 'payment' or 'refund'. Default 'payment'. * @@ -79,7 +79,7 @@ public static function defaultOrderData( $type = 'payment' ): object { * @throws InvalidDataException If the email address is invalid according to `FILTER_SANITIZE_EMAIL` * or `FILTER_VALIDATE_EMAIL` filters. * @since 1.0.0 - * @since 4.1.2 Moved from System. + * @since 4.2.0 Moved from System. */ public static function validateAndSanitizeEmailAddress( string $email ) { $sanitizeEmail = filter_var( $email, FILTER_SANITIZE_EMAIL ); @@ -98,7 +98,7 @@ public static function validateAndSanitizeEmailAddress( string $email ) { * @param string|null $name The full name string to be processed. * @return array An array containing the first and last name, or null if the name is empty. * @since 1.0.0 - * @since 4.1.2 Moved from System. + * @since 4.2.0 Moved from System. */ public static function extractNameParts( $name ): array { if ( empty( $name ) ) { @@ -122,7 +122,7 @@ public static function extractNameParts( $name ): array { * @param int $maxLength The maximum length for the first part of the street address. * @return array The formatted part of the street address. * @since 1.0.0 - * @since 4.1.2 Moved from System. + * @since 4.2.0 Moved from System. */ public static function splitAddress( $data, $maxLength ) { if ( empty( $data->address1 ) ) { @@ -143,7 +143,7 @@ public static function splitAddress( $data, $maxLength ) { * * @return int|float The minor currency amount as an integer, or 0.0 when the amount is empty. * @since 1.0.0 - * @since 4.1.2 Moved from System. Returns 0.0 instead of null when the amount is empty. + * @since 4.2.0 Moved from System. Returns 0.0 instead of null when the amount is empty. */ public static function getMinorAmountBasedOnCurrency( $amount, $currency ) { if ( ! empty( $amount ) ) { @@ -161,7 +161,7 @@ public static function getMinorAmountBasedOnCurrency( $amount, $currency ) { * * @return float The major currency amount, or 0.0 when the amount is empty. * @since 1.0.0 - * @since 4.1.2 Moved from System. Returns 0.0 instead of null when the amount is empty. + * @since 4.2.0 Moved from System. Returns 0.0 instead of null when the amount is empty. */ public static function convertMinorAmountToMajor( $amount, $currency ) { if ( ! empty( $amount ) ) { @@ -177,7 +177,7 @@ public static function convertMinorAmountToMajor( $amount, $currency ) { * @param object $data Contains the order's necessary charges. * @return bool Returns true if the total amount equals zero, false otherwise. * @since 1.0.0 - * @since 4.1.2 Moved from System. + * @since 4.2.0 Moved from System. */ public static function isTotalAmountZero( &$data ): bool { $data->subtotal ??= 0; @@ -196,7 +196,7 @@ public static function isTotalAmountZero( &$data ): bool { * @param object $config Configuration object that provides the URLs and payment method. * @return string The updated webhook URL with encoded data. * @since 1.0.0 - * @since 4.1.2 Moved from System. + * @since 4.2.0 Moved from System. */ public static function updateWebhookUrl( $config ): string { $encodedUrlData = base64_encode( @@ -218,7 +218,7 @@ public static function updateWebhookUrl( $config ): string { /** * Builds the Tutor metadata attached to payment gateway requests. * - * @since 4.1.2 + * @since 4.2.0 * * @param int $order_user_id The ID of the user who placed the order. * @param string $gateway_environment Payment environment mode diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index 668ade8cb9..17aa5d7f62 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -101,7 +101,7 @@ private function parse_response( $response ) { /** * Make HTTP GET request. * - * @since 4.1.2 param $args added + * @since 4.2.0 param $args added * * @param string $url Request URL. * @param array $data Request body. Default empty array. @@ -131,7 +131,7 @@ public static function get( $url, $data = array(), $headers = array(), $args = a /** * Make HTTP POST request. * - * @since 4.1.2 param $args added + * @since 4.2.0 param $args added * * @param string $url Request URL. * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. @@ -212,7 +212,7 @@ public function get_error_message() { /** * Sends an HTTP request and parses the response. * - * @since 4.1.2 + * @since 4.2.0 * * @param string $url Request URL. * @param array $args Optional request arguments. @@ -235,7 +235,7 @@ private static function send( $url, $args = array() ) { /** * Make HTTP PUT request. * - * @since 4.1.2 + * @since 4.2.0 * * @param string $url Request URL. * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. @@ -262,7 +262,7 @@ public static function put( $url, $data = array(), $headers = array(), $args = a /** * Make HTTP PATCH request. * - * @since 4.1.2 + * @since 4.2.0 * * @param string $url Request URL. * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. @@ -289,7 +289,7 @@ public static function patch( $url, $data = array(), $headers = array(), $args = /** * Make HTTP DELETE request. * - * @since 4.1.2 + * @since 4.2.0 * * @param string $url Request URL. * @param array|string $data Request body. An array is form-encoded; a string (e.g. JSON) is sent as-is. Default empty array. diff --git a/tests/phpunit/HttpHelperTest.php b/tests/phpunit/HttpHelperTest.php index 37a6c642ba..eef52c74af 100644 --- a/tests/phpunit/HttpHelperTest.php +++ b/tests/phpunit/HttpHelperTest.php @@ -5,7 +5,7 @@ * @package Tutor\Test * @author Themeum * @link https://themeum.com - * @since 4.1.2 + * @since 4.2.0 */ namespace TutorTest; @@ -18,7 +18,7 @@ * * Requests are intercepted with the `pre_http_request` filter, so no real HTTP call is made. * - * @since 4.1.2 + * @since 4.2.0 */ class HttpHelperTest extends \WP_UnitTestCase { From 6d7043832fa7f2ab80f2e3cfc451fda55ea4c152 Mon Sep 17 00:00:00 2001 From: "Md.Harun-Ur-Rashid" Date: Wed, 7 Oct 2026 15:57:02 +0600 Subject: [PATCH 078/218] docs: add execution command instruction to HttpHelperTest docblock --- tests/phpunit/HttpHelperTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/HttpHelperTest.php b/tests/phpunit/HttpHelperTest.php index eef52c74af..c18b7e57c0 100644 --- a/tests/phpunit/HttpHelperTest.php +++ b/tests/phpunit/HttpHelperTest.php @@ -17,6 +17,7 @@ * HttpHelperTest Class * * Requests are intercepted with the `pre_http_request` filter, so no real HTTP call is made. + * Run test by: vendor/bin/phpunit --filter=HttpHelperTest * * @since 4.2.0 */ From 44b10a67d320a40c40a0bce066ef8561b2e101d6 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Wed, 7 Oct 2026 16:05:34 +0600 Subject: [PATCH 079/218] Rename quiz reveal result attribute Update the revealed quiz question markup to use the `data-reveal-result` attribute instead of `data-result`, so the reveal result is exposed consistently for client-side logic and scripts. --- templates/learning-area/quiz/question.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/learning-area/quiz/question.php b/templates/learning-area/quiz/question.php index bdc309c0eb..0c3cdb94d9 100644 --- a/templates/learning-area/quiz/question.php +++ b/templates/learning-area/quiz/question.php @@ -72,7 +72,7 @@ class="tutor-quiz-question" data-revealed="1" - data-result="" + data-reveal-result="" > From 3e64a54001e0b3b079db4772cf41b84fd0c0953f Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Wed, 7 Oct 2026 16:23:20 +0600 Subject: [PATCH 080/218] fix(settings): confirm penalty unit change with dynamic message --- .../js/admin-dashboard/segments/options.js | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/assets/src/js/admin-dashboard/segments/options.js b/assets/src/js/admin-dashboard/segments/options.js index 9ff71b25c8..40ab53a2c1 100644 --- a/assets/src/js/admin-dashboard/segments/options.js +++ b/assets/src/js/admin-dashboard/segments/options.js @@ -770,7 +770,7 @@ document.addEventListener('DOMContentLoaded', function () { const usageAjaxAction = config.usage_check_action; const confirmationType = config.type || 'turnoff'; - if (!message) { + if (!message && !config.messages) { return; } @@ -844,6 +844,21 @@ document.addEventListener('DOMContentLoaded', function () { } else if (confirmationType === 'change') { document.querySelectorAll(`#field_${fieldKey} select`).forEach((selectElement) => { let previousValue = selectElement.value; + const saveBtn = document.getElementById('save_tutor_option'); + let wasSaveDisabled = saveBtn ? saveBtn.disabled : false; + + const customSelect = selectElement.nextElementSibling; + if (customSelect && customSelect.classList.contains('tutor-js-form-select')) { + customSelect.addEventListener('click', () => { + if (!customSelect.classList.contains('is-active')) { + wasSaveDisabled = saveBtn ? saveBtn.disabled : false; + } + }); + } else { + selectElement.addEventListener('focus', () => { + wasSaveDisabled = saveBtn ? saveBtn.disabled : false; + }); + } selectElement.addEventListener('change', function () { const newValue = this.value; @@ -851,9 +866,6 @@ document.addEventListener('DOMContentLoaded', function () { return; } - const saveBtn = document.getElementById('save_tutor_option'); - const wasSaveDisabled = saveBtn ? saveBtn.disabled : false; - const revertSelect = () => { this.value = previousValue; @@ -870,7 +882,7 @@ document.addEventListener('DOMContentLoaded', function () { optionsWrap.querySelector('.is-active')?.classList.remove('is-active'); const prevItem = optionsWrap.querySelector(`[data-key="${previousValue}"]`); if (prevItem) { - prevItem.classList.add('is-active'); + (prevItem.closest('.tutor-form-select-option') || prevItem).classList.add('is-active'); } } } @@ -880,7 +892,8 @@ document.addEventListener('DOMContentLoaded', function () { } }; - tutorConfirmOptionModal(message, title, cancelText, confirmText).then((confirmed) => { + const modalMessage = (config.messages && config.messages[newValue]) || message; + tutorConfirmOptionModal(modalMessage, title, cancelText, confirmText).then((confirmed) => { if (confirmed) { previousValue = newValue; if (saveBtn) { From 51e1bc3877ee0c061c9a1c511578e904d87c4a03 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Wed, 7 Oct 2026 16:32:09 +0600 Subject: [PATCH 081/218] made exception folder outside of Paypal --- .../{Paypal/src => }/Exceptions/HttpRequestException.php | 3 ++- .../{Paypal/src => }/Exceptions/InvalidDataException.php | 8 ++++---- .../src => }/Exceptions/InvalidSignatureException.php | 8 ++++---- ecommerce/PaymentGateways/Http.php | 2 +- .../PaymentGateways/Paypal/src/Payments/Paypal/Api.php | 2 +- .../PaymentGateways/Paypal/src/Payments/Paypal/Helper.php | 4 +--- .../PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php | 5 +---- ecommerce/PaymentGateways/Utils.php | 2 +- 8 files changed, 15 insertions(+), 19 deletions(-) rename ecommerce/PaymentGateways/{Paypal/src => }/Exceptions/HttpRequestException.php (96%) rename ecommerce/PaymentGateways/{Paypal/src => }/Exceptions/InvalidDataException.php (61%) rename ecommerce/PaymentGateways/{Paypal/src => }/Exceptions/InvalidSignatureException.php (61%) diff --git a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php b/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php similarity index 96% rename from ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php rename to ecommerce/PaymentGateways/Exceptions/HttpRequestException.php index 86534551c4..77f0fe67ce 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Exceptions/HttpRequestException.php +++ b/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php @@ -1,5 +1,6 @@ Date: Wed, 7 Oct 2026 17:00:50 +0600 Subject: [PATCH 082/218] added missing comments --- .../Exceptions/HttpRequestException.php | 10 +++++ .../Exceptions/InvalidDataException.php | 17 ++++++++ .../Exceptions/InvalidSignatureException.php | 17 ++++++++ .../Paypal/src/Payments/Paypal/Api.php | 30 +++++++++++--- .../Paypal/src/Payments/Paypal/Helper.php | 6 +++ .../Paypal/src/Payments/Paypal/Paypal.php | 41 ++++++++++++++++--- 6 files changed, 110 insertions(+), 11 deletions(-) diff --git a/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php b/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php index 77f0fe67ce..5a3eeb4c86 100644 --- a/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php +++ b/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php @@ -1,7 +1,17 @@ accessToken ) ) { @@ -90,11 +98,14 @@ private function getAccessToken(): string { * Creates a new PayPal order by sending a POST request to the PayPal API. * * @since 3.0.0 + * @since 4.2.0 Sends the request through Http::send() instead of Guzzle. * * @param object $data The order data to be sent in the request body. * @param string $order_id A unique identifier for the order. * * @return object The response from the PayPal API, decoded from JSON. + * + * @throws HttpRequestException If the request fails or PayPal returns a 4xx/5xx response. */ public static function createOrder( $data, $order_id ): object { @@ -117,9 +128,11 @@ public static function createOrder( $data, $order_id ): object { * Capture a PayPal payment based on the provided webhook payload. * * @since 3.0.0 + * @since 4.2.0 Sends the request through Http::send() instead of Guzzle. * * @param object $payloadStream The decoded webhook payload containing payment resource data. * @return void + * @throws HttpRequestException If the capture request fails. */ public static function capturePayment( $payloadStream ) { @@ -143,6 +156,7 @@ public static function capturePayment( $payloadStream ) { * Validate the PayPal webhook signature to ensure the authenticity of the incoming event. * * @since 3.9.0 + * @since 4.2.0 Sends the request through Http::send() instead of Guzzle. * * @param object $payload The webhook payload containing stream and server headers. * @return bool True if the webhook signature is verified successfully, false otherwise. @@ -225,6 +239,7 @@ public static function get_order_details( $url ): ?object { * Initiate a refund request for a PayPal order. * * @since 3.9.0 + * @since 4.2.0 Sends the request through Http::send() instead of Guzzle. * * @param string $refund_url The PayPal API refund endpoint URL. * @param string|int $order_id The associated order ID for the refund request. @@ -289,10 +304,15 @@ public static function get_vault_details( $url ): ?object { /** * Retrieves the refund status based on the provided links and type. * - * @param array $links An array of links provided by the PayPal API. - * @param string|null $type The type of link to use. - * * @since 1.0.0 + * @since 4.2.0 Moved from Paypal as a public static method and sends the request through Http::send(). + * + * @param array $links An array of links provided by the PayPal API. + * @param string $type The `rel` of the link to request. Default 'self'. + * + * @return string|null The lowercase refund status, e.g. 'completed'. + * + * @throws HttpRequestException If the request fails or PayPal returns a 4xx/5xx response. */ public static function getRefundStatus( $links, $type = 'self' ): ?string { $url = Helper::getUrl( $links, $type ); diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php index f70a350787..6382bdcf12 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php @@ -9,6 +9,10 @@ /** * Paypal Helper Class + * + * Builds PayPal request data and formats PayPal error responses. + * + * @since 3.0.0 */ final class Helper { @@ -18,6 +22,7 @@ final class Helper { * @param object $data The data object containing item details. * @return array An array of formatted items. * @since 3.0.0 + * @since 4.2.0 Uses Utils::isTotalAmountZero() instead of System::isTotalAmountZero(). */ public static function getItems( &$data ): array { $currency = $data->currency->code; @@ -257,6 +262,7 @@ function ( $message, $issue ) { * @param object $shipping The shipping data containing the receiver's name and address. * @return array The formatted shipping information including type, name, and address. * @since 3.0.0 + * @since 4.2.0 Uses Utils::splitAddress() instead of System::splitAddress(). */ public static function getShippingInfo( $shipping ): array { [$address1, $address2] = Utils::splitAddress( $shipping, 300 ); diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index 0709459bef..a5cac57fe3 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -11,6 +11,14 @@ use Tutor\PaymentGateways\Exceptions\HttpRequestException; use Tutor\PaymentGateways\Utils; +/** + * Paypal payment gateway. + * + * Creates one-time and recurring PayPal orders, handles webhooks and processes refunds. + * + * @since 3.0.0 + * @since 4.2.0 Uses \Tutor\PaymentGateways\Utils and HttpRequestException instead of System and Guzzle. + */ class Paypal extends BasePayment { /** @@ -22,25 +30,34 @@ class Paypal extends BasePayment { protected $config; /** - * @var string|null $orderID - * This property stores the ID of the current order being processed. + * The ID of the current order being processed. + * + * @var string|null * @since 3.0.0 */ protected $orderID; /** - * @var object|null $previousPayload - * This property contains the payload from a previous transaction, used in recurring payments. + * The payload from a previous transaction, used in recurring payments. + * + * @var object|null + * @since 3.0.0 */ protected $previousPayload; /** * The API endpoint URL used to process PayPal refund requests. * - * @var string|null + * @var string|null + * @since 3.9.0 */ protected $refundLink; + /** + * PayPal webhook event types handled by this gateway. + * + * @since 3.0.0 + */ const CHECKOUT_ORDER_APPROVED = 'CHECKOUT.ORDER.APPROVED'; const PAYMENT_CAPTURE_COMPLETED = 'PAYMENT.CAPTURE.COMPLETED'; const PAYMENT_CAPTURE_REFUNDED = 'PAYMENT.CAPTURE.REFUNDED'; @@ -66,6 +83,15 @@ function ( $key ) { return $isConfigOk; } + /** + * Sets up the PayPal API client and requests an access token. + * + * @since 3.0.0 + * + * @return void + * + * @throws Throwable If the access token request fails. + */ public function setup(): void { try { @@ -110,6 +136,7 @@ public function setData( $data ): void { * @param object $data The raw data to be processed. * @return array The structured data for sending to `Paypal Server`. * @since 3.0.0 + * @since 4.2.0 Adds Tutor merchant metadata to the purchase unit description. */ public function prepareData( $data ): array { @@ -194,7 +221,7 @@ public function createPayment() { * * @param object $payload The payload object containing the webhook data. * @return object Returns the processed order data or an error response. - * @throws HttpRequestException If the request fails. + * @throws \Exception If a PayPal API request fails. * @since 3.0.0 * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. */ @@ -246,6 +273,7 @@ public function verifyAndCreateOrderData( object $payload ): object { * @param object $payloadStream The payload stream object containing order and payment details. * @return object The constructed order data object. * @since 3.0.0 + * @since 4.2.0 Uses Utils::defaultOrderData() instead of System::defaultOrderData(). */ private function setReturnData( $payloadStream ): object { $returnData = Utils::defaultOrderData(); @@ -346,6 +374,7 @@ public function createRefund() { * * @throws ErrorException Throws an exception if there is an error during the HTTP request or while processing the refund. * @since 1.0.0 + * @since 4.2.0 Uses Utils::defaultOrderData() and Api::getRefundStatus(), and catches HttpRequestException. */ private function processRefund( $paymentData ): object { $returnData = Utils::defaultOrderData( 'refund' ); From 038341950a70620ee47422bd5bb0b69ce0de5294 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Wed, 7 Oct 2026 17:18:57 +0600 Subject: [PATCH 083/218] Remove reduce motion support from legacy mode --- assets/src/scss/front/course-spotlight/quiz.scss | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/assets/src/scss/front/course-spotlight/quiz.scss b/assets/src/scss/front/course-spotlight/quiz.scss index 258e8e2285..30d186a3c4 100644 --- a/assets/src/scss/front/course-spotlight/quiz.scss +++ b/assets/src/scss/front/course-spotlight/quiz.scss @@ -446,14 +446,4 @@ .rtl & { background-position: right top; } - - [data-tutor-motion='reduce'] & { - animation: none; - background-image: none; - } - - @media (prefers-reduced-motion: reduce) { - animation: none; - background-image: none; - } } From 8b865e29729ff8f08374f323b164f85ac4acc4d8 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Wed, 7 Oct 2026 17:19:28 +0600 Subject: [PATCH 084/218] Fix back button does not update pagination active state --- assets/src/js/front/course/_spotlight-quiz.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index bf8e1986ec..41b9c10ebf 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -224,6 +224,12 @@ window.jQuery(document).ready($ => { clearRevealTimeout(); var $prev = $(this).closest('.quiz-attempt-single-question').hide().prev(); $prev.show(); + + if (has_pagination_enabled() && $('.tutor-quiz-questions-pagination').length) { + $('.tutor-quiz-question-paginate-item').removeClass('active'); + $('.tutor-quiz-questions-pagination a[href="#' + $prev.attr('id') + '"]').addClass('active'); + } + if ($prev.attr('data-revealed') === '1') { $prev.find('.tutor-quiz-next-btn-all').prop('disabled', false); } From ede5b42257c1e61c208677dab0d2b47c01e3d8bd Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Wed, 7 Oct 2026 17:19:33 +0600 Subject: [PATCH 085/218] Update FileUploader.php --- components/FileUploader.php | 1 + 1 file changed, 1 insertion(+) diff --git a/components/FileUploader.php b/components/FileUploader.php index fcaceddedf..39796be2a4 100644 --- a/components/FileUploader.php +++ b/components/FileUploader.php @@ -565,6 +565,7 @@ public function get(): string { // Remove Alpine specific attributes from HTML attributes. unset( $uploader_attributes['onFileSelect'] ); + unset( $uploader_attributes['onFileRemove'] ); unset( $uploader_attributes['onError'] ); $this->attributes = $uploader_attributes; From 6af1d02a80b5cc9b8a7a689b0236b18bbbf6a665 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 8 Oct 2026 10:33:04 +0600 Subject: [PATCH 086/218] Update API documentation to reflect JWT authentication changes - Revised the API documentation in `README.md` to replace Basic Authentication with JWT authentication details, including setup instructions and authentication flow. - Added new authentication-related files for Login, Logout, and Refresh Token endpoints, detailing request structures and expected responses. - Updated existing course and curriculum API endpoints to utilize Bearer JWT for authentication, ensuring consistency across the documentation. - Enhanced environment configuration examples to include new variables for JWT handling. --- docs/api/Authentication/Login.bru | 77 +++++++++++++++++ docs/api/Authentication/Logout.bru | 64 +++++++++++++++ docs/api/Authentication/Refresh Token.bru | 67 +++++++++++++++ docs/api/Authentication/folder.bru | 4 + docs/api/Courses/course-announcement-{id}.bru | 10 +-- docs/api/Courses/course-contents-{id}.bru | 10 +-- docs/api/Courses/course-rating-{id}.bru | 10 +-- docs/api/Courses/courses-{id}.bru | 10 +-- docs/api/Courses/courses.bru | 10 +-- docs/api/Curriculum/lessons.bru | 10 +-- docs/api/Curriculum/topics.bru | 10 +-- .../Instructors/author-information-{id}.bru | 10 +-- .../api/Quizzes/quiz-attempt-details-{id}.bru | 10 +-- .../api/Quizzes/quiz-question-answer-{id}.bru | 10 +-- docs/api/Quizzes/quizzes-{id}.bru | 10 +-- docs/api/Quizzes/quizzes.bru | 10 +-- docs/api/README.md | 82 +++++++++++-------- docs/api/environments/Tutorlms.bru | 4 + docs/api/environments/local.bru.example | 6 +- docs/api/environments/production.bru | 6 +- 20 files changed, 336 insertions(+), 94 deletions(-) create mode 100644 docs/api/Authentication/Login.bru create mode 100644 docs/api/Authentication/Logout.bru create mode 100644 docs/api/Authentication/Refresh Token.bru create mode 100644 docs/api/Authentication/folder.bru create mode 100644 docs/api/environments/Tutorlms.bru diff --git a/docs/api/Authentication/Login.bru b/docs/api/Authentication/Login.bru new file mode 100644 index 0000000000..85467d9e2f --- /dev/null +++ b/docs/api/Authentication/Login.bru @@ -0,0 +1,77 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: {{base_url}}/auth/login + body: json + auth: none +} + +headers { + Tutor-Api-Key: {{api_key}} + Tutor-Api-Secret: {{api_secret}} +} + +body:json { + { + "username": "{{username}}", + "password": "{{password}}" + } +} + +script:post-response { + if (res.status === 200 && res.body && res.body.data) { + bru.setEnvVar("access_token", res.body.data.access_token); + bru.setEnvVar("refresh_token", res.body.data.refresh_token); + } +} + +docs { + # POST /auth/login + + Exchange API credentials and WordPress user credentials for an access JWT and refresh token. + + ## HTTP Method & Route + - **Method:** `POST` + - **Route:** `/wp-json/tutor/v1/auth/login` + + ## Authentication + - **Type:** API Key headers only (no Bearer token yet) + - **Headers:** `Tutor-Api-Key`, `Tutor-Api-Secret` + - **Required Permission:** `Read`, `Read/Write`, or `All` on the API key + + ## Request Body + + | Field | Type | Required | Description | + | :--- | :--- | :--- | :--- | + | `username` | `string` | **Yes** | WordPress username or email. | + | `password` | `string` | **Yes** | WordPress user password. | + + ## Responses + + ### 200 OK (Success) + ```json + { + "code": "tutor_auth_login", + "message": "Login successful.", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 86400, + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user_id": 1, + "display_name": "Admin" + } + } + ``` + + On success, this request stores `access_token` and `refresh_token` in the active Bruno environment for subsequent requests. + + ### 401 Unauthorized + Invalid username or password. + + ### 403 Forbidden + Missing or invalid API key/secret. +} diff --git a/docs/api/Authentication/Logout.bru b/docs/api/Authentication/Logout.bru new file mode 100644 index 0000000000..199fc6268b --- /dev/null +++ b/docs/api/Authentication/Logout.bru @@ -0,0 +1,64 @@ +meta { + name: Logout + type: http + seq: 3 +} + +post { + url: {{base_url}}/auth/logout + body: json + auth: none +} + +headers { + ~Authorization: Bearer {{access_token}} +} + +body:json { + { + "refresh_token": "{{refresh_token}}", + "all": false + } +} + +script:post-response { + if (res.status === 200) { + bru.setEnvVar("access_token", ""); + bru.setEnvVar("refresh_token", ""); + } +} + +docs { + # POST /auth/logout + + Invalidate refresh token(s). Optionally revoke all sessions for the user. + + ## HTTP Method & Route + - **Method:** `POST` + - **Route:** `/wp-json/tutor/v1/auth/logout` + + ## Authentication + - **Type:** Public for single-token logout; Bearer recommended when `all: true` + + ## Request Body + + | Field | Type | Required | Description | + | :--- | :--- | :--- | :--- | + | `refresh_token` | `string` | No* | Specific refresh token to revoke. | + | `all` | `boolean` | No | When `true`, revoke all refresh tokens for the user (requires Bearer access token, or a valid `refresh_token` to resolve the user). | + + \* Provide `refresh_token` and/or set `all: true` with a Bearer token. + + ## Responses + + ### 200 OK (Success) + ```json + { + "code": "tutor_auth_logout", + "message": "Logged out successfully.", + "data": { + "success": true + } + } + ``` +} diff --git a/docs/api/Authentication/Refresh Token.bru b/docs/api/Authentication/Refresh Token.bru new file mode 100644 index 0000000000..4c6ada8334 --- /dev/null +++ b/docs/api/Authentication/Refresh Token.bru @@ -0,0 +1,67 @@ +meta { + name: Refresh Token + type: http + seq: 2 +} + +post { + url: {{base_url}}/auth/refresh + body: json + auth: none +} + +body:json { + { + "refresh_token": "{{refresh_token}}" + } +} + +script:post-response { + if (res.status === 200 && res.body && res.body.data) { + bru.setEnvVar("access_token", res.body.data.access_token); + bru.setEnvVar("refresh_token", res.body.data.refresh_token); + } +} + +docs { + # POST /auth/refresh + + Rotate the access JWT using a valid refresh token. The previous refresh token is consumed and a new pair is issued. + + ## HTTP Method & Route + - **Method:** `POST` + - **Route:** `/wp-json/tutor/v1/auth/refresh` + + ## Authentication + - **Type:** Public (refresh token in body) + - **No API key or Bearer token required** + + ## Request Body + + | Field | Type | Required | Description | + | :--- | :--- | :--- | :--- | + | `refresh_token` | `string` | **Yes** | Refresh token from login (or prior refresh). | + + ## Responses + + ### 200 OK (Success) + ```json + { + "code": "tutor_auth_refresh", + "message": "Token refreshed successfully.", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 86400, + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user_id": 1, + "display_name": "Admin" + } + } + ``` + + ### 401 Unauthorized + Invalid or expired refresh token. + + ### 403 Forbidden + API key bound to the refresh session has been revoked. +} diff --git a/docs/api/Authentication/folder.bru b/docs/api/Authentication/folder.bru new file mode 100644 index 0000000000..268cbf4f68 --- /dev/null +++ b/docs/api/Authentication/folder.bru @@ -0,0 +1,4 @@ +meta { + name: Authentication + seq: 0 +} diff --git a/docs/api/Courses/course-announcement-{id}.bru b/docs/api/Courses/course-announcement-{id}.bru index c5fcfabdda..e9be2c7a35 100644 --- a/docs/api/Courses/course-announcement-{id}.bru +++ b/docs/api/Courses/course-announcement-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/course-announcement/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/course-announcement/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Courses/course-contents-{id}.bru b/docs/api/Courses/course-contents-{id}.bru index 307edb0e26..2073aec93b 100644 --- a/docs/api/Courses/course-contents-{id}.bru +++ b/docs/api/Courses/course-contents-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/course-contents/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/course-contents/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Courses/course-rating-{id}.bru b/docs/api/Courses/course-rating-{id}.bru index 901b029a62..36bd6ab71e 100644 --- a/docs/api/Courses/course-rating-{id}.bru +++ b/docs/api/Courses/course-rating-{id}.bru @@ -7,7 +7,7 @@ meta { get { url: {{base_url}}/course-rating/1 body: none - auth: basic + auth: bearer } params:query { @@ -15,9 +15,8 @@ params:query { ~limit: 10 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -30,7 +29,8 @@ docs { - **Route:** `/wp-json/tutor/v1/course-rating/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Courses/courses-{id}.bru b/docs/api/Courses/courses-{id}.bru index aa973088d4..8f5d400863 100644 --- a/docs/api/Courses/courses-{id}.bru +++ b/docs/api/Courses/courses-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/courses/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/courses/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Courses/courses.bru b/docs/api/Courses/courses.bru index d18e314a43..84fceaca26 100644 --- a/docs/api/Courses/courses.bru +++ b/docs/api/Courses/courses.bru @@ -7,7 +7,7 @@ meta { get { url: {{base_url}}/courses body: none - auth: basic + auth: bearer } params:query { @@ -18,9 +18,8 @@ params:query { ~tags: beginner } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -33,7 +32,8 @@ docs { - **Route:** `/wp-json/tutor/v1/courses` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Query Parameters diff --git a/docs/api/Curriculum/lessons.bru b/docs/api/Curriculum/lessons.bru index 6995d1a2e5..8dacac3779 100644 --- a/docs/api/Curriculum/lessons.bru +++ b/docs/api/Curriculum/lessons.bru @@ -7,16 +7,15 @@ meta { get { url: {{base_url}}/lessons body: none - auth: basic + auth: bearer } params:query { ~topic_id: 1 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -29,7 +28,8 @@ docs { - **Route:** `/wp-json/tutor/v1/lessons` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Query Parameters diff --git a/docs/api/Curriculum/topics.bru b/docs/api/Curriculum/topics.bru index 60555c833e..ff647e908a 100644 --- a/docs/api/Curriculum/topics.bru +++ b/docs/api/Curriculum/topics.bru @@ -7,16 +7,15 @@ meta { get { url: {{base_url}}/topics body: none - auth: basic + auth: bearer } params:query { ~course_id: 1 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -29,7 +28,8 @@ docs { - **Route:** `/wp-json/tutor/v1/topics` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Query Parameters diff --git a/docs/api/Instructors/author-information-{id}.bru b/docs/api/Instructors/author-information-{id}.bru index e759314dfe..a4a1fd2fa2 100644 --- a/docs/api/Instructors/author-information-{id}.bru +++ b/docs/api/Instructors/author-information-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/author-information/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/author-information/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Quizzes/quiz-attempt-details-{id}.bru b/docs/api/Quizzes/quiz-attempt-details-{id}.bru index 054c48c281..073ee7b75b 100644 --- a/docs/api/Quizzes/quiz-attempt-details-{id}.bru +++ b/docs/api/Quizzes/quiz-attempt-details-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/quiz-attempt-details/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/quiz-attempt-details/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Quizzes/quiz-question-answer-{id}.bru b/docs/api/Quizzes/quiz-question-answer-{id}.bru index 1cfd89abe1..dafdb9b744 100644 --- a/docs/api/Quizzes/quiz-question-answer-{id}.bru +++ b/docs/api/Quizzes/quiz-question-answer-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/quiz-question-answer/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/quiz-question-answer/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Quizzes/quizzes-{id}.bru b/docs/api/Quizzes/quizzes-{id}.bru index 3a88cfca42..e05cf4102f 100644 --- a/docs/api/Quizzes/quizzes-{id}.bru +++ b/docs/api/Quizzes/quizzes-{id}.bru @@ -7,12 +7,11 @@ meta { get { url: {{base_url}}/quizzes/1 body: none - auth: basic + auth: bearer } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -25,7 +24,8 @@ docs { - **Route:** `/wp-json/tutor/v1/quizzes/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Path Parameters diff --git a/docs/api/Quizzes/quizzes.bru b/docs/api/Quizzes/quizzes.bru index 5ac2fb7793..7b25c46bcc 100644 --- a/docs/api/Quizzes/quizzes.bru +++ b/docs/api/Quizzes/quizzes.bru @@ -7,16 +7,15 @@ meta { get { url: {{base_url}}/quizzes body: none - auth: basic + auth: bearer } params:query { ~topic_id: 1 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { @@ -29,7 +28,8 @@ docs { - **Route:** `/wp-json/tutor/v1/quizzes` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) + - **Type:** Bearer JWT (`Authorization: Bearer {{access_token}}` or `Tutor-User-Token`) + - **Prerequisite:** Run **Authentication → Login** first to obtain tokens. - **Required Permission:** `Read` or `All` ## Query Parameters diff --git a/docs/api/README.md b/docs/api/README.md index 8872195789..272e7bed62 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -4,45 +4,62 @@ This directory contains the complete, interactive API documentation and executab --- -## 🚀 Getting Started +## Getting Started ### 1. Open in Bruno GUI + 1. Download and install [Bruno](https://www.usebruno.com/downloads). 2. Open Bruno, click **"Open Collection"**, and select the `wp-content/plugins/tutor/docs/api` directory. ### 2. Configure Environment & Authentication -Tutor LMS REST API uses Basic Authentication with **API Keys** and **API Secrets**. - -1. In your WordPress admin dashboard, navigate to **Tutor LMS > Settings > REST API / API**. -2. Generate an API Key & Secret with the desired permission level (`Read`, `Write`, or `All`). -3. Set up your local environment file: - - Make a copy of `environments/local.bru.example` named `environments/local.bru`: - ```bash - cp docs/api/environments/local.bru.example docs/api/environments/local.bru - ``` - - In Bruno, select the **local** environment from the top-right dropdown. - - Configure `base_url`, `api_key`, and `api_secret` in the environment settings (or directly in `environments/local.bru`). Note that `local.bru` is gitignored so your credentials won't be committed. -4. For production environments, configure `environments/production.bru`. ---- +Tutor LMS REST API uses **JWT authentication** (since 4.2.0): + +1. In WordPress admin, go to **Tutor LMS → Settings → REST API**. +2. Generate an API Key & Secret with the desired permission (`Read`, `Write`, or `All`). +3. Set up your local environment: + ```bash + cp docs/api/environments/local.bru.example docs/api/environments/local.bru + ``` +4. In Bruno, select the **local** environment and set: + - `base_url` — e.g. `http://yoursite.local/wp-json/tutor/v1` + - `api_key` / `api_secret` — from Tutor settings + - `username` / `password` — WordPress user credentials +5. Run **Authentication → Login**. It stores `access_token` and `refresh_token` in the environment. +6. All other authenticated requests send `Authorization: Bearer {{access_token}}`. -## 📁 Collection Structure +`local.bru` is gitignored so credentials are not committed. For production, configure `environments/production.bru`. -The collection is organized into the following categories: +### Auth flow -- **`environments/`**: Contains `local.bru.example` and `production.bru`. -- **`Authentication/`**: Overview of authentication mechanisms, key management, and headers. -- **`Courses/`**: Endpoints for querying course listings, course details, complete curriculum trees, announcements, and ratings/reviews. -- **`Curriculum/`**: Topic modules and lesson querying endpoints. -- **`Quizzes/`**: Quiz structures, questions, answers, and student attempt histories. -- **`Instructors/`**: Author/instructor profiles, bio, and associated course references. -- **`Ecommerce & Webhooks/`**: Payment gateway webhooks and processing handlers. +``` +Tutor-Api-Key + Tutor-Api-Secret + username/password + │ + ▼ + POST /auth/login → access_token + refresh_token + │ + ▼ + Authorization: Bearer (or Tutor-User-Token header) + │ + ├── POST /auth/refresh (body: refresh_token) when access expires + └── POST /auth/logout (invalidate refresh token(s)) +``` --- -## 💻 Running Requests via CLI +## Collection Structure -You can execute this collection in terminal or CI/CD pipelines using the Bruno CLI: +- **`environments/`**: `local.bru.example` and `production.bru`. +- **`Authentication/`**: Login, refresh, and logout. +- **`Courses/`**: Course listings, details, curriculum trees, announcements, ratings. +- **`Curriculum/`**: Topics and lessons (read). +- **`Quizzes/`**: Quizzes, questions/answers, attempt details. +- **`Instructors/`**: Author/instructor profiles. +- **`Ecommerce & Webhooks/`**: Payment gateway webhooks (public). + +--- + +## Running Requests via CLI ```bash # Run against the local environment @@ -55,21 +72,22 @@ npx @usebruno/cli run docs/api --env production npx @usebruno/cli run docs/api/Courses --env local ``` ---- +Run **Authentication → Login** first (or ensure `access_token` is set) before authenticated folders. -## 📡 API Response Format +--- -All Tutor LMS REST API endpoints return a standardized JSON envelope: +## API Response Format ```json { "code": "success", "message": "Human readable message", - "data": { ... } + "data": {} } ``` -### Common Status Codes: +### Common Status Codes + - `200 OK`: Request succeeded. -- `401 Unauthorized` / `403 Forbidden`: Missing, invalid, or insufficient API keys / user capabilities. -- `404 Not Found`: Resource (Course, Topic, Lesson, Quiz, or Instructor) does not exist for the provided ID. +- `401 Unauthorized` / `403 Forbidden`: Missing/invalid JWT, revoked API key, or insufficient permission. +- `404 Not Found`: Resource does not exist for the provided ID. diff --git a/docs/api/environments/Tutorlms.bru b/docs/api/environments/Tutorlms.bru new file mode 100644 index 0000000000..f2a427d71d --- /dev/null +++ b/docs/api/environments/Tutorlms.bru @@ -0,0 +1,4 @@ +vars { + base_url: https://tutorlms.test.com/wp-json/tutor/v1 + access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTc5MTQzMzYxNywiaXNzIjoidHV0b3IiLCJ0diI6Miwia2lkIjoxODgxLCJleHAiOjE3OTE1MjAwMTd9.Q4GUH1X03c4cBcd_lLXUoq3yaePC-Rolr8LF3sA2pcs +} diff --git a/docs/api/environments/local.bru.example b/docs/api/environments/local.bru.example index 8a27de6cf9..ec91575e09 100644 --- a/docs/api/environments/local.bru.example +++ b/docs/api/environments/local.bru.example @@ -1,7 +1,11 @@ vars { base_url: http://tutor-dev.local/wp-json/tutor/v1 api_key: + username: } vars:secret [ - api_secret + api_secret, + password, + access_token, + refresh_token ] diff --git a/docs/api/environments/production.bru b/docs/api/environments/production.bru index 4df2c2565c..d09100688c 100644 --- a/docs/api/environments/production.bru +++ b/docs/api/environments/production.bru @@ -1,7 +1,11 @@ vars { base_url: https://example.com/wp-json/tutor/v1 api_key: + username: } vars:secret [ - api_secret + api_secret, + password, + access_token, + refresh_token ] From 082d10ace81fa2b11aff1bf003d1ccf9b9454eaf Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 8 Oct 2026 11:15:29 +0600 Subject: [PATCH 087/218] Enhance quiz answer visibility control in REST API - Added functionality in `REST_Quiz` to conditionally reveal quiz answers based on user permissions and settings. - Implemented `is_quiz_details_hidden_for_user` method in `RestAuth` to check if quiz details should be hidden for students, aligning with the "Hide Quiz Details" option. - Updated quiz answer handling to ensure students do not see correctness when details are hidden or before a finished attempt, improving user experience and security. --- restapi/REST_Quiz.php | 10 ++++++++++ restapi/RestAuth.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/restapi/REST_Quiz.php b/restapi/REST_Quiz.php index 1b5d1486e1..d164e91f8f 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -275,6 +275,7 @@ public function quiz_attempt_details( WP_REST_Request $request ) { $user_id = get_current_user_id(); $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); $can_view_all = $course_id && tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + $reveal_keys = RestAuth::can_reveal_quiz_answers( $quiz_id, $user_id ); // unserialize each attempt info. foreach ( $attempts as $key => $attempt ) { @@ -288,6 +289,15 @@ public function quiz_attempt_details( WP_REST_Request $request ) { $answers = $this->get_quiz_attempt_ans( (int) $attempt->attempt_id ); if ( false !== $answers ) { + // Match frontend: students do not see correctness when details are hidden + // (or before a finished attempt when answer keys stay closed). + if ( ! $can_view_all && ! $reveal_keys ) { + foreach ( $answers as $answer ) { + if ( is_object( $answer ) ) { + unset( $answer->is_correct ); + } + } + } $attempt->attempts_answer = $answers; } else { $attempt->attempts_answer = array(); diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index ab8941f834..843318d16b 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1359,9 +1359,38 @@ public static function is_rest_authenticated() { return static::process_api_request(); } + /** + * Whether quiz attempt details are hidden for a student viewer. + * + * Matches views/quiz/attempt-details.php and attempt-table.php when the + * "Hide Quiz Details" option is enabled. + * + * @since 4.2.0 + * + * @param int $user_id user id. + * + * @return bool + */ + public static function is_quiz_details_hidden_for_user( $user_id = 0 ) { + $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); + if ( ! $user_id || ! tutor_utils()->get_option( 'hide_quiz_details' ) ) { + return false; + } + + // Match views/quiz/attempt-details.php (admin / instructor still see details). + if ( User::is_admin( $user_id ) || User::is_instructor( $user_id, false ) ) { + return false; + } + + return true; + } + /** * Whether answer keys (is_correct) may be revealed. * + * Students never receive answer keys when "Hide Quiz Details" is on, + * matching the frontend attempt-details UI. + * * @since 4.2.0 * * @param int $quiz_id quiz id. @@ -1381,6 +1410,10 @@ public static function can_reveal_quiz_answers( $quiz_id, $user_id = 0 ) { return true; } + if ( self::is_quiz_details_hidden_for_user( $user_id ) ) { + return false; + } + $attempt = ( new QuizModel() )->get_quiz_attempt( $quiz_id, $user_id ); return is_object( $attempt ) && ! empty( $attempt->attempt_ended_at ); } From 64f16ca865ecf44e4ee29a19987dd69f6ee52bc9 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Thu, 8 Oct 2026 11:48:58 +0600 Subject: [PATCH 088/218] Format Quiz.php --- classes/Quiz.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index bc1c12f323..ad06fb0d76 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -921,10 +921,10 @@ public static function manage_attempt_answers( $attempt_answers, $attempt, $atte ); } - $total_marks = 0; - $review_required = false; - $committed_attempt_answers = QuizModel::get_committed_answers_by_attempt( (int) $attempt_id ); - $has_committed_answers = ! empty( $committed_attempt_answers ); + $total_marks = 0; + $review_required = false; + $committed_attempt_answers = QuizModel::get_committed_answers_by_attempt( (int) $attempt_id ); + $has_committed_answers = ! empty( $committed_attempt_answers ); if ( tutor_utils()->count( $quiz_answers ) ) { From 36786fa6416fd5f7195239c70aa062a7b239f127 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Thu, 8 Oct 2026 11:49:10 +0600 Subject: [PATCH 089/218] Fix reduce motion settings. --- assets/src/scss/frontend/learning-area/_quiz.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/src/scss/frontend/learning-area/_quiz.scss b/assets/src/scss/frontend/learning-area/_quiz.scss index 45c2c47e1d..27ff2dab78 100644 --- a/assets/src/scss/frontend/learning-area/_quiz.scss +++ b/assets/src/scss/frontend/learning-area/_quiz.scss @@ -896,8 +896,10 @@ $tutor-quiz-draw-pin-chrome: '#{$tutor-spacing-5} + 2.5rem + #{$tutor-spacing-8} } @media (prefers-reduced-motion: reduce) { - animation: none; - background-image: none; + [data-tutor-motion='auto'] & { + animation: none; + background-image: none; + } } } From 7b542bf37f39a1fdae1f0dc9cc66725e8a35f0c2 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Thu, 8 Oct 2026 13:00:10 +0600 Subject: [PATCH 090/218] updated xss fix and csv security fix in js files only --- GDPR/Controllers/UserConsent.php | 55 +++---------------- .../admin-dashboard/segments/consent-logs.js | 30 +++++++++- .../js/admin-dashboard/segments/students.js | 31 ++++++++++- 3 files changed, 63 insertions(+), 53 deletions(-) diff --git a/GDPR/Controllers/UserConsent.php b/GDPR/Controllers/UserConsent.php index fd108ce1de..896b27abd2 100644 --- a/GDPR/Controllers/UserConsent.php +++ b/GDPR/Controllers/UserConsent.php @@ -110,10 +110,10 @@ public function render_consent_logs_column( $value, $column_name, $user_id ) { return $value; } - $user_name = tutor_json_encode( $this->sanitize_csv_field( $user->display_name ) ) ?? ''; - $user_joined = $this->sanitize_csv_field( $user->user_registered ) ?? ''; - $user_email = $this->sanitize_csv_field( $user->user_email ) ?? ''; - $user_login = $this->sanitize_csv_field( $user->user_login ) ?? ''; + $user_name = $user->display_name ?? ''; + $user_joined = $user->user_registered ?? ''; + $user_email = $user->user_email ?? ''; + $user_login = $user->user_login ?? ''; $avatar_src = get_avatar_url( $user_id, array( 'size' => 40 ) ); $value = ''; + $value = ''; return $value; } @@ -186,21 +184,29 @@ public function handle_ajax_request(): void { switch ( $user_action ) { case 'all_consents_given_by_user': $user_id = Input::post( 'user_id', 0, Input::TYPE_INT ); + $user = $user_id ? get_userdata( $user_id ) : false; - $validate_user = ValidationHelper::validate( - array( 'user_id' => 'required|is_exists' ), - array( 'user_id' => $user_id ) - ); - - if ( ! $validate_user->success ) { + if ( ! $user ) { $this->response_bad_request( __( 'Invalid user ID', 'tutor' ) ); } $consents = $this->get_all_consents_given_by_user( $user_id ); + $html = $this->render_consent_logs_content( $user, $consents ); $this->json_response( __( 'Consent fetched successfully', 'tutor' ), - $consents + array( + 'html' => $html, + 'has_logs' => ! empty( $consents ), + 'logs' => $consents, + 'user' => array( + 'id' => $user->ID, + 'name' => $user->display_name, + 'login' => $user->user_login, + 'email' => $user->user_email, + 'joined' => $user->user_registered, + ), + ) ); break; @@ -299,6 +305,15 @@ function ( $record ) { human_time_diff( $created_at, time() ) ); + // Sanitize fields that may be written to CSV to prevent formula injection. + if ( isset( $record->user_agent ) ) { + $record->user_agent = $this->sanitize_csv_field( $record->user_agent ); + } + + if ( isset( $record->ip_address ) ) { + $record->ip_address = $this->sanitize_csv_field( $record->ip_address ); + } + return $record; }, $records @@ -307,6 +322,31 @@ function ( $record ) { return $records; } + /** + * Sanitize a value for safe inclusion in a CSV cell. + * Prepends a single quote to values that start with CSV formula-injection + * trigger characters (=, +, -, @, tab, carriage-return) so that + * spreadsheet applications do not execute them as formulas. + * + * @since 4.2.1 + * + * @param mixed $value Value to sanitize. + * @return mixed Sanitized value. + */ + private function sanitize_csv_field( $value ) { + if ( ! is_string( $value ) ) { + return $value; + } + + $dangerous_chars = array( '=', '+', '-', '@', "\t", "\r" ); + + if ( in_array( substr( $value, 0, 1 ), $dangerous_chars, true ) ) { + return "'" . $value; + } + + return $value; + } + /** * Build and store give consent * @@ -318,6 +358,7 @@ function ( $record ) { */ private function build_and_store( $consent, $user_data, $display_key ) { $build_consent = LegalConsent::build_consent_snapshot( $consent ); + if ( ! empty( $build_consent ) ) { $build_consent['user_id'] = $user_data->ID; $build_consent['user_email'] = $user_data->user_email; @@ -351,28 +392,6 @@ private function create( array $data ) { return $user_consent_id; } - /** - * Check if a user already gave consent for a display key and version. - * - * @since 4.0.0 - * - * @param string $display_key Consent display key. - * @param string $version Consent version. - * @param int $user_id User ID. Defaults to current user. - * - * @return bool - */ - private function is_consent_given_by_user( string $display_key, string $version, int $user_id ): bool { - $user_data = get_userdata( $user_id ); - if ( ! $user_data ) { - return false; - } - - $given_consent = $this->model->is_consent_given_by_user( $user_id, $display_key, $version ); - - return $given_consent; - } - /** * Render consent logs button. * Called via action hook. @@ -386,12 +405,6 @@ public function render_consent_logs_button( $user_data ): void { if ( ! $user_id ) { return; } - - $user_name = $user_data->display_name ?? ''; - $user_joined = $user_data->user_registered ?? ''; - $user_email = $user_data->user_email ?? ''; - $user_login = $user_data->user_login ?? ''; - $avatar_src = get_avatar_url( $user_id, array( 'size' => 40 ) ); ?>
-
- - +
+
+ +
+ +
-
From db6a93d03851e259f4ebee94c786eb18eb2fdc8a Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 16 Oct 2026 11:27:46 +0600 Subject: [PATCH 185/218] feat(api): enhance error handling and resource validation in REST API - Updated API documentation to clarify error responses for unauthorized access and resource not found scenarios. - Introduced new error handling methods in RestAuth for invalid resource IDs and course validation. - Improved permission checks for accessing course content and author profiles, ensuring proper error responses for invalid requests. - Added detailed JSON error responses for invalid course and topic IDs. --- docs/api/Curriculum/topics.bru | 22 ++++ docs/api/README.md | 4 +- restapi/RestAuth.php | 178 +++++++++++++++++++++++++-------- 3 files changed, 163 insertions(+), 41 deletions(-) diff --git a/docs/api/Curriculum/topics.bru b/docs/api/Curriculum/topics.bru index 7c225f140c..9bdf0820c7 100644 --- a/docs/api/Curriculum/topics.bru +++ b/docs/api/Curriculum/topics.bru @@ -73,6 +73,28 @@ docs { } ``` + ### Invalid / non-course `course_id` + ```json + { + "code": "rest_invalid_resource", + "message": "Course not found.", + "data": { + "status": 404 + } + } + ``` + + ### Real course, no access + ```json + { + "code": "rest_forbidden", + "message": "Sorry, you are not allowed to do that.", + "data": { + "status": 401 + } + } + ``` + ### No topics ```json { diff --git a/docs/api/README.md b/docs/api/README.md index 823185ea70..f37a796fa3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -177,8 +177,8 @@ Send `Authorization: Bearer {{access_token}}` (or `Tutor-User-Token`) on authent - `200 OK`: Request succeeded (Tutor core read/auth routes). - `201 Created`: Not used by Tutor LMS **core** REST routes today (core is mostly `GET` plus auth). Pro write endpoints may return `201` where applicable. - `400 Bad Request`: Validation / missing required parameters. -- `401 Unauthorized` / `403 Forbidden`: Missing/invalid JWT (`rest_missing_access_token`, `rest_invalid_access_token`), revoked API key, or insufficient key permission (`rest_forbidden`). -- `404 Not Found`: Resource or user does not exist for the provided ID. +- `401 Unauthorized` / `403 Forbidden`: Missing/invalid JWT (`rest_missing_access_token`, `rest_invalid_access_token`), revoked API key, insufficient key permission (`rest_forbidden`), or a real resource the caller cannot access. +- `404 Not Found`: Missing/invalid resource id (`rest_invalid_resource`), or empty result sets from controllers when the id is valid. ### Ecommerce webhook exception diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index d0f59ba306..57f5305900 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1409,6 +1409,95 @@ public static function enforce_actor_identity( $response, $handler, $request ) { return $response; } + /** + * WP_Error when a REST resource ID is missing or the wrong type. + * + * Prefer this over returning false (which WordPress maps to rest_forbidden). + * + * @since 4.2.0 + * + * @param string $message Optional human-readable message. + * + * @return \WP_Error + */ + public static function invalid_resource_error( $message = '' ) { + if ( '' === $message ) { + $message = __( 'Invalid resource.', 'tutor' ); + } + + return new \WP_Error( + 'rest_invalid_resource', + $message, + array( 'status' => HttpHelper::STATUS_NOT_FOUND ) + ); + } + + /** + * True when $course_id is a course post type; otherwise a 404 WP_Error. + * + * @since 4.2.0 + * + * @param int $course_id course id. + * + * @return true|\WP_Error + */ + public static function require_valid_course( $course_id ) { + $course_id = absint( $course_id ); + if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { + return self::invalid_resource_error( __( 'Course not found.', 'tutor' ) ); + } + + return true; + } + + /** + * Resolve the parent course from a topic id, or 404 when the topic is invalid. + * + * @since 4.2.0 + * + * @param int $topic_id topic post id. + * + * @return int|\WP_Error Course id on success. + */ + public static function require_course_from_topic( $topic_id ) { + $topic_id = absint( $topic_id ); + if ( ! $topic_id || tutor()->topics_post_type !== get_post_type( $topic_id ) ) { + return self::invalid_resource_error( __( 'Topic not found.', 'tutor' ) ); + } + + $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; + } + + return $course_id; + } + + /** + * Resolve the parent course from a quiz id, or 404 when the quiz is invalid. + * + * @since 4.2.0 + * + * @param int $quiz_id quiz post id. + * + * @return int|\WP_Error Course id on success. + */ + public static function require_course_from_quiz( $quiz_id ) { + $quiz_id = absint( $quiz_id ); + if ( ! $quiz_id || tutor()->quiz_post_type !== get_post_type( $quiz_id ) ) { + return self::invalid_resource_error( __( 'Quiz not found.', 'tutor' ) ); + } + + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; + } + + return $course_id; + } + /** * Whether a user's public profile layout is open (not private). * @@ -1437,16 +1526,21 @@ public static function is_public_profile_layout_open( $user_id ) { * Permission: author card — same gate as public-profile.php. * * Open instructor/student layout → allow (anonymous or JWT). - * Private layout or unknown user → deny (JWT does not bypass). + * Private layout → deny (JWT does not bypass). Unknown user → 404. * * @since 4.2.0 * * @param WP_REST_Request $request request. * - * @return bool + * @return bool|\WP_Error */ public static function permission_public_author( WP_REST_Request $request ) { - return static::is_public_profile_layout_open( absint( $request->get_param( 'id' ) ) ); + $user_id = absint( $request->get_param( 'id' ) ); + if ( ! $user_id || ! get_userdata( $user_id ) ) { + return self::invalid_resource_error( __( 'Author not found.', 'tutor' ) ); + } + + return static::is_public_profile_layout_open( $user_id ); } /** @@ -1460,8 +1554,9 @@ public static function permission_public_author( WP_REST_Request $request ) { */ public static function permission_course_detail( WP_REST_Request $request ) { $course_id = absint( $request->get_param( 'id' ) ); - if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { - return false; + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; } $token_error = self::get_access_token_error( false ); @@ -1496,43 +1591,27 @@ public static function permission_course_detail( WP_REST_Request $request ) { } /** - * Resolve course id from common REST route params. + * Permission: course learning content — JWT path or public course (no token). * - * Prefers `id`, then `course_id`. Returns 0 unless the value is a course post type. + * Used by course-contents. Announcements use permission_authenticated_course_content. * * @since 4.2.0 * * @param WP_REST_Request $request request. * - * @return int + * @return bool|\WP_Error */ - private static function course_id_from_request( WP_REST_Request $request ) { + public static function permission_course_content( WP_REST_Request $request ) { $course_id = absint( $request->get_param( 'id' ) ); if ( ! $course_id ) { $course_id = absint( $request->get_param( 'course_id' ) ); } - if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { - return 0; + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; } - return $course_id; - } - - /** - * Permission: course learning content — JWT path or public course (no token). - * - * Used by course-contents. Announcements use permission_authenticated_course_content. - * - * @since 4.2.0 - * - * @param WP_REST_Request $request request. - * - * @return bool|\WP_Error - */ - public static function permission_course_content( WP_REST_Request $request ) { - $course_id = self::course_id_from_request( $request ); - $token_error = self::get_access_token_error( false ); if ( is_wp_error( $token_error ) ) { return $token_error; @@ -1542,7 +1621,7 @@ public static function permission_course_content( WP_REST_Request $request ) { return static::can_view_course_content( $course_id ); } - return $course_id && Course_List::is_public( $course_id ); + return Course_List::is_public( $course_id ); } /** @@ -1564,7 +1643,17 @@ public static function permission_authenticated_course_content( WP_REST_Request return false; } - return static::can_view_course_content( self::course_id_from_request( $request ) ); + $course_id = absint( $request->get_param( 'id' ) ); + if ( ! $course_id ) { + $course_id = absint( $request->get_param( 'course_id' ) ); + } + + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; + } + + return static::can_view_course_content( $course_id ); } /** @@ -1584,11 +1673,16 @@ public static function permission_topics( WP_REST_Request $request ) { return $token_error; } + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; + } + if ( static::process_api_request() ) { return static::can_view_course_content( $course_id ); } - return $course_id && Course_List::is_public( $course_id ); + return Course_List::is_public( $course_id ); } /** @@ -1601,19 +1695,21 @@ public static function permission_topics( WP_REST_Request $request ) { * @return bool|\WP_Error */ public static function permission_by_topic( WP_REST_Request $request ) { - $topic_id = absint( $request->get_param( 'topic_id' ) ); - $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); - $token_error = self::get_access_token_error( false ); if ( is_wp_error( $token_error ) ) { return $token_error; } + $course_id = self::require_course_from_topic( absint( $request->get_param( 'topic_id' ) ) ); + if ( is_wp_error( $course_id ) ) { + return $course_id; + } + if ( static::process_api_request() ) { return static::can_view_course_content( $course_id ); } - return $course_id && Course_List::is_public( $course_id ); + return Course_List::is_public( $course_id ); } /** @@ -1635,8 +1731,10 @@ public static function permission_authenticated_by_topic( WP_REST_Request $reque return false; } - $topic_id = absint( $request->get_param( 'topic_id' ) ); - $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); + $course_id = self::require_course_from_topic( absint( $request->get_param( 'topic_id' ) ) ); + if ( is_wp_error( $course_id ) ) { + return $course_id; + } return static::can_view_course_content( $course_id ); } @@ -1660,8 +1758,10 @@ public static function permission_quiz( WP_REST_Request $request ) { return false; } - $quiz_id = absint( $request->get_param( 'id' ) ); - $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + $course_id = self::require_course_from_quiz( absint( $request->get_param( 'id' ) ) ); + if ( is_wp_error( $course_id ) ) { + return $course_id; + } return static::can_view_course_content( $course_id ); } From bdd4c95e3b81801a8fc5c90646ca6738e11e61b9 Mon Sep 17 00:00:00 2001 From: b-l-i-n-d Date: Fri, 16 Oct 2026 12:50:52 +0600 Subject: [PATCH 186/218] feat(security): add sanitizeCsvCell utility function --- assets/core/ts/utils/security.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/assets/core/ts/utils/security.ts b/assets/core/ts/utils/security.ts index 99215ff33a..2f8fd88162 100644 --- a/assets/core/ts/utils/security.ts +++ b/assets/core/ts/utils/security.ts @@ -42,3 +42,35 @@ export function escapeAttr(str: string): string { .replace(//g, '>'); } + +/** + * Formula injection prevention prefixes (OWASP / CWE-1236) + */ +const FORMULA_INJECTION_PREFIXES = ['=', '+', '-', '@', '\t', '\r'] as const; + +/** + * Sanitizes a single cell value for CSV export to prevent formula injection (OWASP / CWE-1236) + * and properly escape commas, double quotes, and newlines. + * + * @param rawVal - The raw value to sanitize + * @returns The sanitized cell string + * + * @since 4.2.0 + */ +export function sanitizeCsvCell(rawVal: unknown): string { + if (rawVal === null || rawVal === undefined) { + return ''; + } + + let str = String(rawVal); + + if (FORMULA_INJECTION_PREFIXES.some((prefix) => str.startsWith(prefix))) { + str = `'${str}`; + } + + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + + return str; +} From a24e9c836e4a4bb4418899e346466c2523f4c6c2 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 16 Oct 2026 16:26:24 +0600 Subject: [PATCH 187/218] refactor(api-keys): streamline API key management functionality - Removed deprecated copy-to-clipboard functionality from the API key management script. - Updated the UI to utilize data attributes for clipboard copy actions, enhancing maintainability and readability. - Introduced a new regression test for REST API permissions to ensure robust authentication and authorization checks. --- .../segments/manage-api-keys.js | 34 ------------------- v2-library/src/js/popupMenu.js | 14 ++++---- views/pages/tools/manage-tokens.php | 4 +-- 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/assets/src/js/admin-dashboard/segments/manage-api-keys.js b/assets/src/js/admin-dashboard/segments/manage-api-keys.js index 3365e7cff2..63d0f22c87 100644 --- a/assets/src/js/admin-dashboard/segments/manage-api-keys.js +++ b/assets/src/js/admin-dashboard/segments/manage-api-keys.js @@ -15,8 +15,6 @@ document.addEventListener("DOMContentLoaded", async function() { const credentialsPanel = document.getElementById("tutor-generated-credentials"); const generatedKeyInput = document.getElementById("tutor-generated-api-key"); const generatedSecretInput = document.getElementById("tutor-generated-api-secret"); - const copyKeyBtn = document.getElementById("tutor-copy-generated-api-key"); - const copySecretBtn = document.getElementById("tutor-copy-generated-api-secret"); const noRecordElem = document.getElementById("tutor-api-keys-no-record"); const tokenSettingsForm = document.getElementById("tutor-rest-api-token-settings"); const tokenSettingsSubmitBtn = document.querySelector("#tutor-rest-api-token-settings button[type=submit]"); @@ -32,20 +30,6 @@ document.addEventListener("DOMContentLoaded", async function() { const isSuccessResponse = (res) => Number(res?.status_code) === 200; const getErrorMessage = (res) => res?.message || defaultErrMsg; - const copyToClipboard = async (text) => { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - - const temp = document.createElement("textarea"); - temp.value = text; - document.body.appendChild(temp); - temp.select(); - document.execCommand("copy"); - document.body.removeChild(temp); - }; - const resetGenerateModal = () => { if (!apiKeysForm) { return; @@ -93,24 +77,6 @@ document.addEventListener("DOMContentLoaded", async function() { } }); - copyKeyBtn?.addEventListener("click", async () => { - try { - await copyToClipboard(generatedKeyInput?.value || ""); - tutor_toast(__("Success", "tutor"), __("API key copied to clipboard", "tutor"), "success"); - } catch (error) { - tutor_toast(__("Failed", "tutor"), defaultErrMsg, "error"); - } - }); - - copySecretBtn?.addEventListener("click", async () => { - try { - await copyToClipboard(generatedSecretInput?.value || ""); - tutor_toast(__("Success", "tutor"), __("Secret copied to clipboard", "tutor"), "success"); - } catch (error) { - tutor_toast(__("Failed", "tutor"), defaultErrMsg, "error"); - } - }); - // Save token lifetime settings if (tokenSettingsForm) { tokenSettingsForm.onsubmit = async (e) => { diff --git a/v2-library/src/js/popupMenu.js b/v2-library/src/js/popupMenu.js index 7e1fc95c8a..1af47573f8 100644 --- a/v2-library/src/js/popupMenu.js +++ b/v2-library/src/js/popupMenu.js @@ -101,9 +101,11 @@ const showToolTip = (targetEl, text = 'Copied!') => { document.addEventListener('click', async (e) => { const copyTargetAttr = 'data-tutor-clipboard-copy-target'; const pasteTargetAttr = 'data-tutor-clipboard-paste-target'; + const copyTrigger = e.target.closest(`[${copyTargetAttr}]`); + const pasteTrigger = e.target.closest(`[${pasteTargetAttr}]`); - if (e.target.hasAttribute(copyTargetAttr)) { - const id = e.target.getAttribute(copyTargetAttr); + if (copyTrigger) { + const id = copyTrigger.getAttribute(copyTargetAttr); /* Get the text field */ const text = document.getElementById(id).value; @@ -111,19 +113,19 @@ document.addEventListener('click', async (e) => { /* Copy text into clipboard */ if (text) { await navigator.clipboard.writeText(text); - showToolTip(e.target, 'Copied'); + showToolTip(copyTrigger, 'Copied'); } } - if (e.target.hasAttribute(pasteTargetAttr)) { - const id = e.target.getAttribute(pasteTargetAttr); + if (pasteTrigger) { + const id = pasteTrigger.getAttribute(pasteTargetAttr); const text = await navigator.clipboard.readText(); /* Pasting on the text field */ if (text) { document.getElementById(id).value = text; - showToolTip(e.target, 'Pasted'); + showToolTip(pasteTrigger, 'Pasted'); } } }); diff --git a/views/pages/tools/manage-tokens.php b/views/pages/tools/manage-tokens.php index 2a997c2892..683f631d71 100644 --- a/views/pages/tools/manage-tokens.php +++ b/views/pages/tools/manage-tokens.php @@ -235,7 +235,7 @@ class="tutor-form-control" readonly value="" > - @@ -253,7 +253,7 @@ class="tutor-form-control" readonly value="" > - From ced34cf06fb6d9f2f6b99db8f1cd34638d366a71 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 16 Oct 2026 16:30:36 +0600 Subject: [PATCH 188/218] refactor(popupMenu): simplify clipboard interaction logic - Updated clipboard copy and paste functionality to directly reference the target element, improving code readability and maintainability. - Removed unnecessary closest element checks, streamlining event handling for clipboard actions. --- v2-library/src/js/popupMenu.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/v2-library/src/js/popupMenu.js b/v2-library/src/js/popupMenu.js index 1af47573f8..7e1fc95c8a 100644 --- a/v2-library/src/js/popupMenu.js +++ b/v2-library/src/js/popupMenu.js @@ -101,11 +101,9 @@ const showToolTip = (targetEl, text = 'Copied!') => { document.addEventListener('click', async (e) => { const copyTargetAttr = 'data-tutor-clipboard-copy-target'; const pasteTargetAttr = 'data-tutor-clipboard-paste-target'; - const copyTrigger = e.target.closest(`[${copyTargetAttr}]`); - const pasteTrigger = e.target.closest(`[${pasteTargetAttr}]`); - if (copyTrigger) { - const id = copyTrigger.getAttribute(copyTargetAttr); + if (e.target.hasAttribute(copyTargetAttr)) { + const id = e.target.getAttribute(copyTargetAttr); /* Get the text field */ const text = document.getElementById(id).value; @@ -113,19 +111,19 @@ document.addEventListener('click', async (e) => { /* Copy text into clipboard */ if (text) { await navigator.clipboard.writeText(text); - showToolTip(copyTrigger, 'Copied'); + showToolTip(e.target, 'Copied'); } } - if (pasteTrigger) { - const id = pasteTrigger.getAttribute(pasteTargetAttr); + if (e.target.hasAttribute(pasteTargetAttr)) { + const id = e.target.getAttribute(pasteTargetAttr); const text = await navigator.clipboard.readText(); /* Pasting on the text field */ if (text) { document.getElementById(id).value = text; - showToolTip(pasteTrigger, 'Pasted'); + showToolTip(e.target, 'Pasted'); } } }); From 845050e8b1575d8d6e3c9082520fb3c4bfaa4a17 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:25:01 +0600 Subject: [PATCH 189/218] Coupon: Updated coupon table & prevented race condition issue --- classes/Tutor.php | 8 ++ classes/Upgrader.php | 47 +++++++ ecommerce/CheckoutController.php | 35 ++++- ecommerce/CouponController.php | 57 ++++++-- ecommerce/HooksHandler.php | 29 +++- models/CouponModel.php | 231 +++++++++++++++++++++++++++---- 6 files changed, 366 insertions(+), 41 deletions(-) diff --git a/classes/Tutor.php b/classes/Tutor.php index e92ba61e39..46fe06b26c 100644 --- a/classes/Tutor.php +++ b/classes/Tutor.php @@ -11,6 +11,7 @@ namespace TUTOR; use Tutor\Models\CourseModel; +use Tutor\Models\CouponModel; use Tutor\Ecommerce\Ecommerce; use Tutor\GDPR\GDPR; use Tutor\Helpers\QueryHelper; @@ -994,9 +995,16 @@ public static function create_database() { id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, coupon_code VARCHAR(50) NOT NULL, user_id BIGINT(20) UNSIGNED NOT NULL, + order_id BIGINT(20) UNSIGNED DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT '" . CouponModel::STATUS_CONSUMED . "', + expires_at_gmt DATETIME DEFAULT NULL, + created_at_gmt DATETIME NOT NULL, PRIMARY KEY (id), + UNIQUE KEY coupon_order (coupon_code, order_id), KEY coupon_code (coupon_code), KEY user_id (user_id), + KEY order_id (order_id), + KEY status (status), CONSTRAINT fk_tutor_coupon_usage_coupon_code FOREIGN KEY (coupon_code) REFERENCES {$wpdb->prefix}tutor_coupons(coupon_code) ON DELETE CASCADE, CONSTRAINT fk_tutor_coupon_usage_user_id FOREIGN KEY (user_id) REFERENCES {$wpdb->prefix}users(ID) ON DELETE CASCADE ) $charset_collate;"; diff --git a/classes/Upgrader.php b/classes/Upgrader.php index 37781f0769..83a0330491 100644 --- a/classes/Upgrader.php +++ b/classes/Upgrader.php @@ -13,6 +13,7 @@ use Tutor\Ecommerce\CartController; use Tutor\Ecommerce\CheckoutController; use Tutor\Helpers\QueryHelper; +use Tutor\Models\CouponModel; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -90,6 +91,7 @@ public function available_upgrades() { $upgrades[] = 'upgrade_to_3_7_1'; $upgrades[] = 'upgrade_to_3_8_0'; $upgrades[] = 'upgrade_to_3_8_2'; + $upgrades[] = 'upgrade_to_4_2_0'; } return $upgrades; @@ -380,6 +382,51 @@ public function install_gradebook() { } } + /** + * Upgrade to version 4.2.0 + * + * Add order_id, status, and expiration to tutor_coupon_usages. + * + * @since 4.2.0 + * + * @return void + */ + public function upgrade_to_4_2_0() { + $is_coupon_table_updated = update_option( 'tutor_coupon_table_updated', false ); + if ( $is_coupon_table_updated ) { + return; + } + + if ( version_compare( TUTOR_VERSION, '4.2.0', '>=' ) ) { + global $wpdb; + + $table_name = "{$wpdb->prefix}tutor_coupon_usages"; + if ( QueryHelper::table_exists( $table_name ) ) { + $charset_collate = $wpdb->get_charset_collate(); + $coupon_usage_table = "CREATE TABLE {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + coupon_code VARCHAR(50) NOT NULL, + user_id BIGINT(20) UNSIGNED NOT NULL, + order_id BIGINT(20) UNSIGNED DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT '" . CouponModel::STATUS_CONSUMED . "', + expires_at_gmt DATETIME DEFAULT NULL, + created_at_gmt DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY coupon_order (coupon_code, order_id), + KEY coupon_code (coupon_code), + KEY user_id (user_id), + KEY order_id (order_id), + KEY status (status) + ) $charset_collate;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $coupon_usage_table ); + + update_option( 'tutor_coupon_table_updated', true, false ); + } + } + } + /** * Email table deployment * diff --git a/ecommerce/CheckoutController.php b/ecommerce/CheckoutController.php index e9cc2e02e1..3521212afd 100644 --- a/ecommerce/CheckoutController.php +++ b/ecommerce/CheckoutController.php @@ -764,6 +764,20 @@ public function pay_now() { } if ( ! empty( $order_data ) ) { + $placed_order_id = is_array( $order_data ) ? (int) ( $order_data['id'] ?? 0 ) : (int) $order_data; + + if ( ! empty( $checkout_data->coupon_code ) && $placed_order_id ) { + $reserved = $this->coupon_model->reserve_coupon_usage( $placed_order_id, $checkout_data->coupon_code, (int) $current_user_id, 30 ); + if ( ! $reserved ) { + // Mark order cancelled and return error so student can re-try. + $order_model = new OrderModel(); + $order_model->update_order( $placed_order_id, array( 'order_status' => OrderModel::ORDER_CANCELLED ) ); + $err_msg = __( 'Coupon is no longer available or usage limit exceeded!', 'tutor' ); + set_transient( self::PAY_NOW_ERROR_TRANSIENT_KEY . $current_user_id, array( $err_msg ) ); + return; + } + } + do_action( 'tutor_after_checkout_consent', $current_user_id, $validate_consent ); if ( 'automate' === $payment_type ) { try { @@ -1177,7 +1191,26 @@ public function pay_incomplete_order() { } } - $payment_data = $this->prepare_payment_data( (array) $order_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type ); + // Revalidate coupon before proceeding to payment. + if ( ! empty( $order_data->coupon_code ) ) { + $coupon = $this->coupon_model->get_coupon_by_code( $order_data->coupon_code ); + if ( ! $coupon || ! $this->coupon_model->is_coupon_valid( $coupon, (int) $order_data->user_id, (int) $order_id ) ) { + $err_msg = __( 'The coupon applied to this order is no longer valid or its usage limit has been reached.', 'tutor' ); + tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, $err_msg ); + return; + } + + // Renew reservation hold for this payment attempt. + $reserved = $this->coupon_model->reserve_coupon_usage( (int) $order_id, $order_data->coupon_code, (int) $order_data->user_id, 30 ); + if ( ! $reserved ) { + $err_msg = __( 'The coupon applied to this order is no longer available.', 'tutor' ); + tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, $order_data->id, $err_msg ); + return; + } + } + + // $payment_data = $this->prepare_payment_data( (array) $order_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type ); + $payment_data = $this->prepare_payment_data( (array) $order_data ); $this->proceed_to_payment( $payment_data, $payment_method ? $payment_method : $order_data->payment_method, $order_data->order_type ); } catch ( \Throwable $th ) { tutor_log( $th ); diff --git a/ecommerce/CouponController.php b/ecommerce/CouponController.php index ef3d66474f..794284a829 100644 --- a/ecommerce/CouponController.php +++ b/ecommerce/CouponController.php @@ -916,6 +916,7 @@ public function ajax_apply_coupon() { * Store usage upon order completion * * @since 3.0.0 + * @since 4.2.0 Promote reserved usage or insert consumed usage. * * @param int $order_id Order id. * @@ -927,21 +928,59 @@ public function store_coupon_usage( $order_id ) { $order = $order_model->get_order_by_id( $order_id ); if ( $order ) { if ( $order->coupon_amount > 0 && $order_model::ORDER_COMPLETED === $order->order_status ) { - // Store coupon usage. - $data = array( - 'coupon_code' => $order->coupon_code, - 'user_id' => $order->user_id, - ); + // Promote existing reservation. + $promoted = $this->model->promote_coupon_usage( $order_id ); + + // Fallback if no reservation existed (e.g. legacy or direct order). + if ( ! $promoted || 0 === $this->model->get_order_usage_count( $order_id ) ) { + $data = array( + 'coupon_code' => $order->coupon_code, + 'user_id' => $order->user_id, + 'order_id' => $order_id, + 'status' => CouponModel::STATUS_CONSUMED, + 'expires_at_gmt' => null, + 'created_at_gmt' => current_time( 'mysql', true ), + ); - try { - $this->model->store_coupon_usage( $data ); - } catch ( \Throwable $th ) { - tutor_log( $th ); + try { + $this->model->store_coupon_usage( $data ); + } catch ( \Throwable $th ) { + tutor_log( $th ); + } } } } } + /** + * Reserve coupon usage for an order + * + * @since 4.2.0 + * + * @param int $order_id Order id. + * @param string $coupon_code Coupon code. + * @param int $user_id User id. + * @param int $ttl_minutes Expiry in minutes. Default 30. + * + * @return bool + */ + public function reserve_coupon_usage( int $order_id, string $coupon_code, int $user_id, int $ttl_minutes = 30 ): bool { + return $this->model->reserve_coupon_usage( $order_id, $coupon_code, $user_id, $ttl_minutes ); + } + + /** + * Cancel coupon usage for an order + * + * @since 4.2.0 + * + * @param int $order_id Order id. + * + * @return bool + */ + public function cancel_coupon_usage( int $order_id ): bool { + return $this->model->cancel_coupon_usage( $order_id ); + } + /** * Validate input data based on predefined rules. * diff --git a/ecommerce/HooksHandler.php b/ecommerce/HooksHandler.php index 84ae7416e5..44a5a15158 100644 --- a/ecommerce/HooksHandler.php +++ b/ecommerce/HooksHandler.php @@ -268,8 +268,13 @@ public function handle_payment_status_changed( $order_id, $prev_payment_status, $this->manage_earnings_and_enrollments( $order_status, $order_id ); - // Store coupon usage. - $this->coupon_ctrl->store_coupon_usage( $order_id ); + if ( OrderModel::PAYMENT_PAID === $new_payment_status ) { + // Promote coupon usage to consumed. + $this->coupon_ctrl->store_coupon_usage( $order_id ); + } elseif ( in_array( $new_payment_status, array( OrderModel::PAYMENT_FAILED, OrderModel::PAYMENT_REFUNDED ), true ) || OrderModel::ORDER_CANCELLED === $order_status ) { + // Release coupon reservation / mark cancelled. + $this->coupon_ctrl->cancel_coupon_usage( $order_id ); + } } /** @@ -498,9 +503,21 @@ public function update_order_data( array $order_data ) { */ public function handle_free_checkout( array $order_data ) { if ( empty( $order_data['total_price'] ) && OrderModel::TYPE_SINGLE_ORDER === $order_data['order_type'] ) { - $order_id = $order_data['id']; - $user_id = $order_data['user_id']; - $items = $order_data['items']; + $order_id = $order_data['id']; + $user_id = $order_data['user_id']; + $items = $order_data['items']; + $coupon_code = $order_data['coupon_code'] ?? ''; + + // If a coupon brought the total to zero, reserve coupon usage before enrolling. + if ( ! empty( $coupon_code ) ) { + $reserved = $this->coupon_ctrl->reserve_coupon_usage( $order_id, $coupon_code, $user_id, 30 ); + if ( ! $reserved ) { + // Limit exceeded concurrently; cancel order and prevent enrollment. + $this->order_model->update_order( $order_id, array( 'order_status' => OrderModel::ORDER_CANCELLED ) ); + return $order_data; + } + } + foreach ( $items as $item ) { $is_gift_item = apply_filters( 'tutor_is_gift_item', false, $item ); if ( $is_gift_item ) { @@ -515,7 +532,7 @@ public function handle_free_checkout( array $order_data ) { } } - // Store coupon usage. + // Promote coupon usage to consumed upon completed enrollment. $this->coupon_ctrl->store_coupon_usage( $order_id ); } return $order_data; diff --git a/models/CouponModel.php b/models/CouponModel.php index 7184a5ca55..73be54f5f3 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -10,6 +10,7 @@ namespace Tutor\Models; +use Tutor\Core\DB; use TUTOR\Course; use Tutor\Helpers\QueryHelper; @@ -22,6 +23,7 @@ class CouponModel { * Coupon status * * @since 3.0.0 + * @since 4.2.0 Reserved, consumed & cancelled status added * * @var string */ @@ -30,6 +32,9 @@ class CouponModel { const STATUS_TRASH = 'trash'; const STATUS_EXPIRED = 'expired'; const STATUS_SCHEDULED = 'scheduled'; + const STATUS_RESERVED = 'reserved'; + const STATUS_CONSUMED = 'consumed'; + const STATUS_CANCELLED = 'cancelled'; /** * Coupon type @@ -496,40 +501,58 @@ public function get_coupon_count( $where = array(), string $search_term = '' ) { * Get coupon usage count * * @since 3.0.0 + * @since 4.2.0 Added exclude_order_id and active status filter. * * @param mixed $coupon_code Coupon code. + * @param int $exclude_order_id Optional order ID to exclude from count. * * @return int */ - public function get_coupon_usage_count( $coupon_code ) { - return QueryHelper::get_count( - $this->coupon_usage_table, - array( 'coupon_code' => $coupon_code ), - array(), - '*' - ); + public function get_coupon_usage_count( $coupon_code, int $exclude_order_id = 0 ) { + $now_gmt = current_time( 'mysql', true ); + + $query = DB::table( $this->coupon_usage_table ) + ->where( 'coupon_code', $coupon_code ) + ->where_raw( + '(status IS NULL OR status = %s OR (status = %s AND (expires_at_gmt IS NULL OR expires_at_gmt > %s)))', + array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) + ); + + if ( $exclude_order_id > 0 ) { + $query->where_raw( '(order_id IS NULL OR order_id != %d)', array( $exclude_order_id ) ); + } + + return $query->count(); } /** * Get coupon usage count for a user * * @since 3.0.0 + * @since 4.2.0 Added exclude_order_id and active status filter. * * @param mixed $coupon_code Coupon code. * @param int $user_id User id. + * @param int $exclude_order_id Optional order ID to exclude from count. * * @return int */ - public function get_user_usage_count( $coupon_code, $user_id ) { - return QueryHelper::get_count( - $this->coupon_usage_table, - array( - 'coupon_code' => $coupon_code, - 'user_id' => $user_id, - ), - array(), - '*' - ); + public function get_user_usage_count( $coupon_code, $user_id, int $exclude_order_id = 0 ) { + $now_gmt = current_time( 'mysql', true ); + + $query = DB::table( $this->coupon_usage_table ) + ->where( 'coupon_code', $coupon_code ) + ->where( 'user_id', $user_id ) + ->where_raw( + '(status IS NULL OR status = %s OR (status = %s AND (expires_at_gmt IS NULL OR expires_at_gmt > %s)))', + array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) + ); + + if ( $exclude_order_id > 0 ) { + $query->where_raw( '(order_id IS NULL OR order_id != %d)', array( $exclude_order_id ) ); + } + + return $query->count(); } /** @@ -863,18 +886,23 @@ public static function set_apply_coupon_error( $error ) { * Considering start-expire time & use limit. * * @since 3.0.0 + * @since 4.2.0 Added user_id and exclude_order_id parameters. * * @param object $coupon Coupon object. + * @param int $user_id Optional user id. + * @param int $exclude_order_id Optional order ID to exclude from usage limit. * * @return bool */ - public function is_coupon_valid( object $coupon ): bool { + public function is_coupon_valid( object $coupon, int $user_id = 0, int $exclude_order_id = 0 ): bool { if ( self::STATUS_INACTIVE === $coupon->coupon_status || self::STATUS_TRASH === $coupon->coupon_status ) { self::set_apply_coupon_error( $this->get_coupon_failed_error_msg( 'invalid' ) ); return false; } - return self::STATUS_ACTIVE === $coupon->coupon_status && $this->has_coupon_validity( $coupon ) && $this->has_user_usage_limit( $coupon, get_current_user_id() ); + $user_id = $user_id ? $user_id : get_current_user_id(); + + return self::STATUS_ACTIVE === $coupon->coupon_status && $this->has_coupon_validity( $coupon ) && $this->has_user_usage_limit( $coupon, $user_id, $exclude_order_id ); } /** @@ -986,7 +1014,7 @@ public function is_coupon_requirement_meet( $item_id, object $coupon, $order_typ $total_price += $course_price->sale_price ? $course_price->sale_price : $course_price->regular_price; if ( ! $course_price->sale_price ) { - $regular_price_item_count++; + ++$regular_price_item_count; } } @@ -1035,20 +1063,22 @@ public function has_coupon_validity( object $coupon ): bool { * Check coupon usage limit * * @since 3.0.0 + * @since 4.2.0 Added exclude_order_id parameter. * * @param object $coupon coupon object. * @param int $user_id user id. + * @param int $exclude_order_id Optional order ID to exclude. * * @return bool true if has usage limit otherwise false */ - public function has_user_usage_limit( object $coupon, int $user_id ): bool { + public function has_user_usage_limit( object $coupon, int $user_id, int $exclude_order_id = 0 ): bool { $has_limit = true; $total_usage_limit = (int) $coupon->total_usage_limit; $user_usage_limit = (int) $coupon->per_user_usage_limit; if ( $total_usage_limit > 0 ) { - $coupon_usage_count = $this->get_coupon_usage_count( $coupon->coupon_code ); + $coupon_usage_count = $this->get_coupon_usage_count( $coupon->coupon_code, $exclude_order_id ); if ( $coupon_usage_count >= $total_usage_limit ) { $has_limit = false; self::set_apply_coupon_error( $this->get_coupon_failed_error_msg( 'usage_limit_exceeded' ) ); @@ -1056,14 +1086,14 @@ public function has_user_usage_limit( object $coupon, int $user_id ): bool { } if ( $user_usage_limit > 0 ) { - $user_usage_count = $this->get_user_usage_count( $coupon->coupon_code, $user_id ); + $user_usage_count = $this->get_user_usage_count( $coupon->coupon_code, $user_id, $exclude_order_id ); if ( $user_usage_count >= $user_usage_limit ) { $has_limit = false; self::set_apply_coupon_error( $this->get_coupon_failed_error_msg( 'user_usage_limit_exceeded' ) ); } } - return apply_filters( 'tutor_coupon_has_user_usage_limit', $has_limit, $coupon, $user_id ); + return apply_filters( 'tutor_coupon_has_user_usage_limit', $has_limit, $coupon, $user_id, $exclude_order_id ); } /** @@ -1193,12 +1223,163 @@ public function is_specific_applies_to( string $applies_to ) { */ public function store_coupon_usage( array $data ) { try { - return QueryHelper::insert( $this->coupon_usage_table, $data ); + return DB::table( $this->coupon_usage_table )->insert( $data ); } catch ( \Throwable $th ) { throw $th; } } + /** + * Reserve coupon usage for an order atomically under a row lock. + * + * @since 4.2.0 + * + * @param int $order_id Order id. + * @param string $coupon_code Coupon code. + * @param int $user_id User id. + * @param int $ttl_minutes Hold duration in minutes. Default 30. + * + * @return bool True on success, false on limit exceeded or failure. + */ + public function reserve_coupon_usage( int $order_id, string $coupon_code, int $user_id, int $ttl_minutes = 30 ): bool { + global $wpdb; + + if ( ! $order_id || empty( $coupon_code ) || ! $user_id ) { + return false; + } + + // Begin transaction. + $wpdb->query( 'START TRANSACTION' ); + + try { + // Lock the coupon row for update to prevent concurrent race conditions. + $coupon = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$this->coupons_table} WHERE coupon_code = %s FOR UPDATE", + $coupon_code + ) + ); + + if ( ! $coupon || ! $this->is_coupon_valid( $coupon, $user_id, $order_id ) ) { + $wpdb->query( 'ROLLBACK' ); + return false; + } + + $now_gmt = current_time( 'mysql', true ); + $expires_gmt = gmdate( 'Y-m-d H:i:s', strtotime( "+{$ttl_minutes} minutes", strtotime( $now_gmt ) ) ); + + // Check if reservation record already exists for this order. + $existing = DB::table( $this->coupon_usage_table ) + ->where( 'coupon_code', $coupon_code ) + ->where( 'order_id', $order_id ) + ->first(); + + if ( $existing ) { + $update_status = self::STATUS_CONSUMED === $existing->status ? self::STATUS_CONSUMED : self::STATUS_RESERVED; + DB::table( $this->coupon_usage_table ) + ->where( 'id', $existing->id ) + ->update( + array( + 'status' => $update_status, + 'expires_at_gmt' => $expires_gmt, + ) + ); + } else { + DB::table( $this->coupon_usage_table ) + ->insert( + array( + 'coupon_code' => $coupon_code, + 'user_id' => $user_id, + 'order_id' => $order_id, + 'status' => self::STATUS_RESERVED, + 'expires_at_gmt' => $expires_gmt, + 'created_at_gmt' => $now_gmt, + ) + ); + } + + $wpdb->query( 'COMMIT' ); + return true; + } catch ( \Throwable $th ) { + $wpdb->query( 'ROLLBACK' ); + tutor_log( $th ); + return false; + } + } + + /** + * Promote reserved coupon usage to consumed on payment completion. + * + * @since 4.2.0 + * + * @param int $order_id Order id. + * + * @return bool + */ + public function promote_coupon_usage( int $order_id ): bool { + if ( ! $order_id ) { + return false; + } + + try { + return DB::table( $this->coupon_usage_table ) + ->where( 'order_id', $order_id ) + ->update( + array( + 'status' => self::STATUS_CONSUMED, + 'expires_at_gmt' => null, + ) + ); + } catch ( \Throwable $th ) { + tutor_log( $th ); + return false; + } + } + + /** + * Cancel coupon usage for an order (releases limit hold while preserving record). + * + * @since 4.2.0 + * + * @param int $order_id Order id. + * + * @return bool + */ + public function cancel_coupon_usage( int $order_id ): bool { + if ( ! $order_id ) { + return false; + } + + try { + return DB::table( $this->coupon_usage_table ) + ->where( 'order_id', $order_id ) + ->update( + array( + 'status' => self::STATUS_CANCELLED, + 'expires_at_gmt' => null, + ) + ); + } catch ( \Throwable $th ) { + tutor_log( $th ); + return false; + } + } + + /** + * Get usage count for a specific order. + * + * @since 4.2.0 + * + * @param int $order_id Order id. + * + * @return int + */ + public function get_order_usage_count( int $order_id ): int { + return DB::table( $this->coupon_usage_table ) + ->where( 'order_id', $order_id ) + ->count(); + } + /** * Delete coupon usage by using the where condition * From 167943e89610ced1de2b07afee7f3f0440f3ea37 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:31:52 +0600 Subject: [PATCH 190/218] Refactor variable naming --- ecommerce/CheckoutController.php | 2 +- models/CouponModel.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ecommerce/CheckoutController.php b/ecommerce/CheckoutController.php index 3521212afd..e984e19023 100644 --- a/ecommerce/CheckoutController.php +++ b/ecommerce/CheckoutController.php @@ -764,7 +764,7 @@ public function pay_now() { } if ( ! empty( $order_data ) ) { - $placed_order_id = is_array( $order_data ) ? (int) ( $order_data['id'] ?? 0 ) : (int) $order_data; + $placed_order_id = is_array( $order_data ) && isset( $order_data['id'] ) ? (int) ( $order_data['id'] ?? 0 ) : 0; if ( ! empty( $checkout_data->coupon_code ) && $placed_order_id ) { $reserved = $this->coupon_model->reserve_coupon_usage( $placed_order_id, $checkout_data->coupon_code, (int) $current_user_id, 30 ); diff --git a/models/CouponModel.php b/models/CouponModel.php index 73be54f5f3..0b853c5f70 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -1237,11 +1237,11 @@ public function store_coupon_usage( array $data ) { * @param int $order_id Order id. * @param string $coupon_code Coupon code. * @param int $user_id User id. - * @param int $ttl_minutes Hold duration in minutes. Default 30. + * @param int $reserve_duration Hold duration in minutes. Default 30. * * @return bool True on success, false on limit exceeded or failure. */ - public function reserve_coupon_usage( int $order_id, string $coupon_code, int $user_id, int $ttl_minutes = 30 ): bool { + public function reserve_coupon_usage( int $order_id, string $coupon_code, int $user_id, int $reserve_duration = 30 ): bool { global $wpdb; if ( ! $order_id || empty( $coupon_code ) || ! $user_id ) { @@ -1266,7 +1266,7 @@ public function reserve_coupon_usage( int $order_id, string $coupon_code, int $u } $now_gmt = current_time( 'mysql', true ); - $expires_gmt = gmdate( 'Y-m-d H:i:s', strtotime( "+{$ttl_minutes} minutes", strtotime( $now_gmt ) ) ); + $expires_gmt = gmdate( 'Y-m-d H:i:s', strtotime( "+{$reserve_duration} minutes", strtotime( $now_gmt ) ) ); // Check if reservation record already exists for this order. $existing = DB::table( $this->coupon_usage_table ) From 9685319c20b18b360782ab17713f6b497d430aec Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:47:07 +0600 Subject: [PATCH 191/218] Fix: Called updated_option instead of get_option --- classes/Upgrader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/Upgrader.php b/classes/Upgrader.php index 83a0330491..287b536c98 100644 --- a/classes/Upgrader.php +++ b/classes/Upgrader.php @@ -392,7 +392,7 @@ public function install_gradebook() { * @return void */ public function upgrade_to_4_2_0() { - $is_coupon_table_updated = update_option( 'tutor_coupon_table_updated', false ); + $is_coupon_table_updated = get_option( 'tutor_coupon_table_updated', false ); if ( $is_coupon_table_updated ) { return; } From 3041619d462612a9a54cb62ba3b38f34a7574ceb Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 15:48:54 +0600 Subject: [PATCH 192/218] Use quiz answer stats for earned marks Replace the direct SUM query with QuizModel::get_attempt_answers_stats() to retrieve an attempt's total earned marks. --- classes/Quiz.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 0c29ae0b4d..f7428995ef 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -2043,15 +2043,7 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str * @return float */ private function calculate_attempt_earned_marks( int $attempt_id ): float { - $marks = QueryHelper::query( - 'tutor_quiz_attempt_answers', - array( - 'select' => 'SUM(achieved_mark) AS earned_marks', - 'where' => array( 'quiz_attempt_id' => $attempt_id ), - ) - ); - - return max( 0.0, (float) ( $marks[0]->earned_marks ?? 0.0 ) ); + return QuizModel::get_attempt_answers_stats( $attempt_id )->total_earned_marks; } /** From 50a1584d643f060f0c7ac5ddf51d0a87b864a4a4 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 15:54:49 +0600 Subject: [PATCH 193/218] Handle floats without decimal parts Cast float values to strings before splitting and default missing decimal digits to zero, preventing errors for whole-number floats and values without a decimal point. --- helpers/QueryHelper.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 8285b2f330..76ecd042d5 100644 --- a/helpers/QueryHelper.php +++ b/helpers/QueryHelper.php @@ -1049,9 +1049,9 @@ public static function prepare_value( $value ) { if ( is_int( $value ) ) { $escaped_value = $wpdb->prepare( '%d', $value ); } elseif ( is_float( $value ) ) { - list( $whole, $decimal ) = explode( '.', $value ); - $expression = '%.' . strlen( $decimal ) . 'f'; - $escaped_value = $wpdb->prepare( $expression, $value );//phpcs:ignore + $value_parts = explode( '.', (string) $value ); + $expression = '%.' . strlen( $value_parts[1] ?? '' ) . 'f'; + $escaped_value = $wpdb->prepare( $expression, $value );//phpcs:ignore } else { $escaped_value = $wpdb->prepare( '%s', $value ); } @@ -1509,5 +1509,4 @@ public static function get_table_schema( $table_name ) { return $result; } - } From 9ca3130fe368caa4718418a93de7d90853094af6 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 15:55:10 +0600 Subject: [PATCH 194/218] Skip required validation for revealed answers Treat committed quiz answers as revealed and exempt them from required-answer validation, so users can continue without re-answering questions they've already submitted. --- assets/src/js/front/course/_spotlight-quiz.js | 3 +++ templates/learning-area/quiz/attempt.php | 3 ++- templates/learning-area/quiz/question.php | 9 ++++----- templates/single/quiz/parts/choice-box.php | 2 +- templates/single/quiz/parts/question.php | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index f87a46b49e..e843a06c1a 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -135,6 +135,9 @@ window.jQuery(document).ready($ => { */ function tutor_quiz_validation($question_wrap, validated) { + if ($question_wrap.attr('data-revealed') === '1') { + return validated; + } var $required_answer_wrap = $question_wrap.find('.quiz-answer-required'); diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index 309ee5d51e..43e5353f97 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -217,7 +217,8 @@ class="tutor-quiz-questions" do_action( 'tutor_quiz/body/before', $tutor_is_started_quiz->quiz_id, $quiz_attempt_info ); foreach ( $questions as $index => $question ) { $question_settings = maybe_unserialize( $question->question_settings ); - $answer_required = isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; + $is_committed_question = ! empty( $committed_attempt_answers[ (int) $question->question_id ] ); + $answer_required = ! $is_committed_question && isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; $question_index = $index + 1; $question->committed_answer = $committed_attempt_answers[ (int) $question->question_id ] ?? null; ?> diff --git a/templates/learning-area/quiz/question.php b/templates/learning-area/quiz/question.php index 0c3cdb94d9..e44e92f8ef 100644 --- a/templates/learning-area/quiz/question.php +++ b/templates/learning-area/quiz/question.php @@ -49,12 +49,11 @@ $question_id = (int) ( $question->question_id ?? 0 ); $field_name_base = sprintf( 'attempt[%d][quiz_question][%d]', $attempt_id, $question_id ); -$answer_is_required = isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; +$committed_answer = $committed_answer ?? ( $question->committed_answer ?? null ); +$is_revealed = ! empty( $committed_answer ); +$answer_is_required = ! $is_revealed && isset( $question_settings['answer_required'] ) && '1' === $question_settings['answer_required']; $required_message = __( 'The answer for this question is required', 'tutor' ); - -$committed_answer = $committed_answer ?? ( $question->committed_answer ?? null ); -$is_revealed = ! empty( $committed_answer ); -$reveal_result = ''; +$reveal_result = ''; if ( $is_revealed ) { if ( '1' === (string) $committed_answer->is_correct ) { $reveal_result = 'correct'; diff --git a/templates/single/quiz/parts/choice-box.php b/templates/single/quiz/parts/choice-box.php index 726423c459..6c0fcbef33 100644 --- a/templates/single/quiz/parts/choice-box.php +++ b/templates/single/quiz/parts/choice-box.php @@ -67,7 +67,7 @@ } ?> -
> +
>
diff --git a/templates/single/quiz/parts/question.php b/templates/single/quiz/parts/question.php index b38fa0aa7f..1e1a314033 100644 --- a/templates/single/quiz/parts/question.php +++ b/templates/single/quiz/parts/question.php @@ -123,7 +123,7 @@ class="quiz-attempt-single-question quiz-attempt-single-question-question_id, $rand_choice ); $show_question_mark = (bool) tutor_utils()->avalue_dot( 'show_question_mark', $question_settings ); - $answer_required = (bool) tutor_utils()->array_get( 'answer_required', $question_settings ); + $answer_required = ! $is_question_committed && (bool) tutor_utils()->array_get( 'answer_required', $question_settings ); echo wp_kses( '
', array( From 600e7d753fd70b61da28c9938e11855f77fd4052 Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Mon, 19 Oct 2026 16:37:32 +0600 Subject: [PATCH 195/218] Handle missing content bank question flags Build the content bank flag map only for questions with an ID and use safe empty checks when filtering question and answer payloads, avoiding undefined-index notices. --- classes/QuizBuilder.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index ef28599d6d..5cf125000b 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -942,15 +942,21 @@ private function set_current_quiz_questions_answer_ids( int $quiz_id ) { public function is_valid_quiz_question_answer_payload( array $payload ): bool { $payload_question_ids = wp_list_pluck( $payload, 'question_id' ); $payload_question_answers = wp_list_pluck( $payload, 'question_answers', 'question_id' ); - $is_cb_question = wp_list_pluck( $payload, 'is_cb_question', 'question_id' ); + + $is_cb_question = array(); + foreach ( $payload as $question ) { + if ( isset( $question['question_id'] ) ) { + $is_cb_question[ $question['question_id'] ] = ! empty( $question['is_cb_question'] ); + } + } // Remove content bank answers. - $payload_question_answers = array_filter( $payload_question_answers, fn( $question_id ) => ! $is_cb_question[ $question_id ], ARRAY_FILTER_USE_KEY ); + $payload_question_answers = array_filter( $payload_question_answers, fn( $question_id ) => empty( $is_cb_question[ $question_id ] ), ARRAY_FILTER_USE_KEY ); $payload_question_answers = array_filter( array_values( $payload_question_answers ) ); // Filter out empty array. $payload_answer_ids = wp_list_pluck( array_merge( ...$payload_question_answers ), 'answer_id' ); // Remove the hash id. - $payload_question_ids = array_filter( $payload_question_ids, fn( $id ) => is_numeric( $id ) && ! $is_cb_question[ $id ] ); + $payload_question_ids = array_filter( $payload_question_ids, fn( $id ) => is_numeric( $id ) && empty( $is_cb_question[ $id ] ) ); $payload_answer_ids = array_filter( $payload_answer_ids, fn( $id ) => is_numeric( $id ) ); // For the new quiz current_quiz is null, in this there should not be any numeric question or answer id. From a0313f9f7cbe0f032a775089aead25737376853c Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:49:16 +0600 Subject: [PATCH 196/218] Is reservation required check added --- ecommerce/CheckoutController.php | 6 +++--- models/CouponModel.php | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/ecommerce/CheckoutController.php b/ecommerce/CheckoutController.php index e984e19023..88100028da 100644 --- a/ecommerce/CheckoutController.php +++ b/ecommerce/CheckoutController.php @@ -765,9 +765,9 @@ public function pay_now() { if ( ! empty( $order_data ) ) { $placed_order_id = is_array( $order_data ) && isset( $order_data['id'] ) ? (int) ( $order_data['id'] ?? 0 ) : 0; - - if ( ! empty( $checkout_data->coupon_code ) && $placed_order_id ) { - $reserved = $this->coupon_model->reserve_coupon_usage( $placed_order_id, $checkout_data->coupon_code, (int) $current_user_id, 30 ); + $coupon_code = is_array( $order_data ) && isset( $order_data['coupon_code'] ) ? $order_data['coupon_code'] : $checkout_data->coupon_code; + if ( $placed_order_id && ! empty( $coupon_code ) && $this->coupon_model->is_reservation_required( $coupon_code ) ) { + $reserved = $this->coupon_model->reserve_coupon_usage( $placed_order_id, $coupon_code, (int) $current_user_id, 30 ); if ( ! $reserved ) { // Mark order cancelled and return error so student can re-try. $order_model = new OrderModel(); diff --git a/models/CouponModel.php b/models/CouponModel.php index 0b853c5f70..83d249405e 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -1255,7 +1255,7 @@ public function reserve_coupon_usage( int $order_id, string $coupon_code, int $u // Lock the coupon row for update to prevent concurrent race conditions. $coupon = $wpdb->get_row( $wpdb->prepare( - "SELECT * FROM {$this->coupons_table} WHERE coupon_code = %s FOR UPDATE", + "SELECT * FROM {$this->table_name} WHERE coupon_code = %s FOR UPDATE", $coupon_code ) ); @@ -1479,4 +1479,25 @@ public static function is_scheduled( object $coupon ): bool { return $start_date > $now && ( ! $expire_date || $expire_date > $now ); } + + /** + * Check if the coupon requires reservation management + * + * @since 4.2.0 + * + * @param mixed $coupon_code Coupon code. + * + * @return bool True|False + */ + public function is_reservation_required( $coupon_code ): bool { + $coupon = DB::table( $this->table_name )->where( 'coupon_code', $coupon_code )->first(); + if ( $coupon ) { + $total_usage_limit = (int) $coupon->total_usage_limit; + $user_usage_limit = (int) $coupon->per_user_usage_limit; + + return $total_usage_limit || $user_usage_limit; + } + + return false; + } } From 8bdd809b03cc598fcf07296677e01204bd9043ce Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Thu, 22 Oct 2026 13:03:15 +0600 Subject: [PATCH 197/218] get_json() issue in `HttpHelper` --- helpers/HttpHelper.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index 17aa5d7f62..c89c48a2ab 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -167,10 +167,14 @@ public function get_body() { /** * Get body data as JSON * + * @since 4.2.0 param $associative added + * + * @param ?bool $associative When TRUE, JSON objects will be returned as associative arrays; when FALSE, JSON objects will be returned as objects. When NULL, JSON objects will be returned as associative arrays or objects depending on whether JSON_OBJECT_AS_ARRAY is set in the flags. + * * @return mixed */ - public function get_json() { - return json_decode( $this->body ); + public function get_json( ?bool $associative = null ) { + return json_decode( $this->body, $associative ); } /** From bd51745344d63ae63bb2cf8e164a0cd5bca08898 Mon Sep 17 00:00:00 2001 From: sanjana4khan Date: Thu, 22 Oct 2026 14:47:55 +0600 Subject: [PATCH 198/218] refactor the comment --- helpers/HttpHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/HttpHelper.php b/helpers/HttpHelper.php index c89c48a2ab..e29aff347c 100644 --- a/helpers/HttpHelper.php +++ b/helpers/HttpHelper.php @@ -169,7 +169,7 @@ public function get_body() { * * @since 4.2.0 param $associative added * - * @param ?bool $associative When TRUE, JSON objects will be returned as associative arrays; when FALSE, JSON objects will be returned as objects. When NULL, JSON objects will be returned as associative arrays or objects depending on whether JSON_OBJECT_AS_ARRAY is set in the flags. + * @param ?bool $associative True returns arrays, false returns objects, null follows the JSON_OBJECT_AS_ARRAY flag. * * @return mixed */ From 7c7281d284b08f536e638396c9354835747b7fdd Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 23 Oct 2026 10:34:22 +0600 Subject: [PATCH 199/218] Enhance REST API access control for course ratings and content visibility - Updated `RestAPI` to allow access to non-published posts based on enrollment status. - Improved `REST_Rating` to reflect UI access rules for course ratings, including handling guest users and their ratings. - Added a new method `can_read_course_rating` to encapsulate logic for determining if a user can read course ratings based on course status and user authentication. - Enhanced `RestAuth` to clarify access permissions for course content, ensuring that enrollment alone does not grant access to non-published content. --- classes/RestAPI.php | 5 +-- restapi/REST_Rating.php | 69 +++++++++++++++++++++++++++++++++++++---- restapi/RestAuth.php | 18 ++++++++++- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index df42978121..b99daeb771 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -194,8 +194,9 @@ public function check_permission( $response, $handler, $request ) { ); } - // Prevent access to non-publish posts, only author / course managers can access. - if ( 'publish' !== $post->post_status && ! $can_edit ) { + // Non-publish: editors always; readers only when can_view_course_content allows + // (private + enrolled/public/IA — not draft/pending/trash via enrollment alone). + if ( 'publish' !== $post->post_status && ! $can_edit && ! RestAuth::can_view_course_content( $id ) ) { return new \WP_Error( 'rest_forbidden', tutor_utils()->error_message(), diff --git a/restapi/REST_Rating.php b/restapi/REST_Rating.php index ab974856b2..a5c000c0d0 100644 --- a/restapi/REST_Rating.php +++ b/restapi/REST_Rating.php @@ -52,8 +52,13 @@ class REST_Rating { /** * Retrieve course ratings via REST API. * + * Mirrors plugin UI reviews access: + * - Published courses: public (guest reduced payload). + * - Private courses: enrolled / course-content access only. + * - Authenticated callers get their own rating (approved or hold). + * * @since 1.7.1 - * @since 4.2.0 Guest payload matches reviews UI; require published course. + * @since 4.2.0 Guest payload matches reviews UI; private courses for enrolled viewers; expose my_rating. * * @param WP_REST_Request $request The REST request object. * @@ -67,7 +72,7 @@ public function course_rating( WP_REST_Request $request ) { $offset = ! empty( $offset ) ? $offset : 0; $limit = ! empty( $limit ) ? min( $limit, 100 ) : 10; - if ( ! CourseModel::get_post_types( $this->post_id ) || 'publish' !== get_post_status( $this->post_id ) ) { + if ( ! $this->can_read_course_rating( $this->post_id ) ) { return $this->response( 'tutor_read_rating', __( 'Course not found', 'tutor' ), @@ -76,10 +81,18 @@ public function course_rating( WP_REST_Request $request ) { ); } - $ratings = tutor_utils()->get_course_rating( $this->post_id ); - $reviews = tutor_utils()->get_course_reviews( $this->post_id, $offset, $limit, false, array( 'approved' ) ); + $is_guest = ! RestAuth::is_rest_authenticated(); + $current_user_id = $is_guest ? 0 : (int) tutor_utils()->get_user_id(); - $is_guest = ! RestAuth::is_rest_authenticated(); + $ratings = tutor_utils()->get_course_rating( $this->post_id ); + $reviews = tutor_utils()->get_course_reviews( + $this->post_id, + $offset, + $limit, + false, + array( 'approved' ), + $current_user_id + ); if ( $is_guest ) { $payload = (object) array( @@ -108,7 +121,17 @@ public function course_rating( WP_REST_Request $request ) { ); } - $ratings->reviews = is_array( $reviews ) ? $reviews : array(); + $my_rating = tutor_utils()->get_reviews_by_user( + $current_user_id, + 0, + 1, + false, + $this->post_id, + array( 'approved', 'hold' ) + ); + + $ratings->reviews = is_array( $reviews ) ? $reviews : array(); + $ratings->my_rating = $my_rating ? $my_rating : null; foreach ( $ratings->reviews as $review ) { $user_id = isset( $review->user_id ) ? (int) $review->user_id : 0; @@ -117,6 +140,10 @@ public function course_rating( WP_REST_Request $request ) { } } + if ( $ratings->my_rating && ! RestAuth::can_view_user_private_fields( $current_user_id ) ) { + unset( $ratings->my_rating->comment_author_email ); + } + return $this->response( 'tutor_read_rating', __( 'Course rating retrieved successfully', 'tutor' ), @@ -124,4 +151,34 @@ public function course_rating( WP_REST_Request $request ) { HttpHelper::STATUS_OK ); } + + /** + * Whether the caller may read ratings for the course. + * + * Matches UI: publish is public; private requires enrolled / content access. + * + * @since 4.2.0 + * + * @param int $course_id Course ID. + * + * @return bool + */ + private function can_read_course_rating( $course_id ) { + $course_id = absint( $course_id ); + if ( ! $course_id || ! CourseModel::get_post_types( $course_id ) ) { + return false; + } + + $status = get_post_status( $course_id ); + + if ( CourseModel::STATUS_PUBLISH === $status ) { + return true; + } + + if ( CourseModel::STATUS_PRIVATE === $status ) { + return RestAuth::is_rest_authenticated() && RestAuth::can_view_course_content( $course_id ); + } + + return false; + } } diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 57f5305900..5daa65adc2 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1769,6 +1769,10 @@ public static function permission_quiz( WP_REST_Request $request ) { /** * Whether the user may view full course learning content. * + * Matches learning-area UI: students may access publish/private only. + * Draft, pending, trash, future, etc. require edit or IA content access — + * enrollment alone is not enough. + * * @since 4.2.0 * * @param int $course_id course id. @@ -1782,11 +1786,23 @@ public static function can_view_course_content( $course_id, $user_id = 0 ) { return false; } + $user_id = tutor_utils()->get_user_id( $user_id ); + $status = get_post_status( $course_id ); + + // Non-publish / non-private: editors and IA content access only (not enrollment). + if ( ! in_array( $status, array( CourseModel::STATUS_PUBLISH, CourseModel::STATUS_PRIVATE ), true ) ) { + if ( ! $user_id ) { + return false; + } + + return tutor_utils()->can_user_edit_course( $user_id, $course_id ) + || (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + } + if ( Course_List::is_public( $course_id ) ) { return true; } - $user_id = tutor_utils()->get_user_id( $user_id ); if ( ! $user_id ) { return false; } From 1c2d4ae7991f2f3f2b28f129c52bacc4139d05a9 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 23 Oct 2026 11:19:12 +0600 Subject: [PATCH 200/218] Update course rating API documentation for clarity and detail - Revised the description to specify that the endpoint retrieves ratings for a course, not just published courses. - Clarified authentication requirements for public and private courses, including access conditions for enrolled users. - Enhanced the response structure to include the user's own rating details when available, improving the API's usability. --- docs/api/Courses/course-rating-{id}.bru | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/api/Courses/course-rating-{id}.bru b/docs/api/Courses/course-rating-{id}.bru index c47af0c3e3..ffc86b349f 100644 --- a/docs/api/Courses/course-rating-{id}.bru +++ b/docs/api/Courses/course-rating-{id}.bru @@ -22,16 +22,17 @@ auth:bearer { docs { # GET /course-rating/{id} - Retrieves rating summary and reviews for a published course. + Retrieves rating summary and reviews for a course. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/course-rating/:id` ## Authentication - - **Public** (`__return_true`). Bearer JWT optional. + - **Public** (`__return_true`) for **published** courses. Bearer JWT optional. + - **Private** courses: requires a valid access token and enrolled / course-content access (same as plugin UI). Otherwise `401`/`403` from the global course privacy gate (or `404` from the rating handler if reached without access). - Guests get a reduced review payload (display name, content, date, rating only). - - Authenticated callers get the full rating object (including `rating_sum`) and full approved review rows (`comment_author_email` omitted unless the caller may view private user fields). + - Authenticated callers get the full rating object (including `rating_sum`), full approved review rows (`comment_author_email` omitted unless the caller may view private user fields), plus `my_rating` (own review with status `approved` or `hold`, or `null`). ## Path Parameters @@ -101,7 +102,16 @@ docs { "comment_date_gmt": "2026-01-10 12:00:00", "rating": 5 } - ] + ], + "my_rating": { + "comment_ID": "55", + "comment_post_ID": "1", + "user_id": "20", + "display_name": "Sam", + "comment_content": "Helpful lessons.", + "comment_status": "approved", + "rating": 5 + } } } ``` @@ -116,4 +126,5 @@ docs { } } ``` + Returned when the course is missing, not a course post type, not `publish`/`private`, or is `private` without enrolled / content access. } From b8d24871be44237977e70c51f91fa271703b824f Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:20:43 +0600 Subject: [PATCH 201/218] Refactor schema to avoid duplication --- classes/Tutor.php | 58 ++++++++++++++++++++++++++++++-------------- classes/Upgrader.php | 20 +-------------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/classes/Tutor.php b/classes/Tutor.php index 46fe06b26c..c794601f74 100644 --- a/classes/Tutor.php +++ b/classes/Tutor.php @@ -11,7 +11,6 @@ namespace TUTOR; use Tutor\Models\CourseModel; -use Tutor\Models\CouponModel; use Tutor\Ecommerce\Ecommerce; use Tutor\GDPR\GDPR; use Tutor\Helpers\QueryHelper; @@ -991,23 +990,7 @@ public static function create_database() { CONSTRAINT fk_tutor_coupon_application_coupon_code FOREIGN KEY (coupon_code) REFERENCES {$wpdb->prefix}tutor_coupons(coupon_code) ON DELETE CASCADE ) $charset_collate;"; - $coupon_usage_table = "CREATE TABLE {$wpdb->prefix}tutor_coupon_usages ( - id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, - coupon_code VARCHAR(50) NOT NULL, - user_id BIGINT(20) UNSIGNED NOT NULL, - order_id BIGINT(20) UNSIGNED DEFAULT NULL, - status VARCHAR(20) NOT NULL DEFAULT '" . CouponModel::STATUS_CONSUMED . "', - expires_at_gmt DATETIME DEFAULT NULL, - created_at_gmt DATETIME NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY coupon_order (coupon_code, order_id), - KEY coupon_code (coupon_code), - KEY user_id (user_id), - KEY order_id (order_id), - KEY status (status), - CONSTRAINT fk_tutor_coupon_usage_coupon_code FOREIGN KEY (coupon_code) REFERENCES {$wpdb->prefix}tutor_coupons(coupon_code) ON DELETE CASCADE, - CONSTRAINT fk_tutor_coupon_usage_user_id FOREIGN KEY (user_id) REFERENCES {$wpdb->prefix}users(ID) ON DELETE CASCADE - ) $charset_collate;"; + $coupon_usage_table = self::get_coupon_usage_table_schema( true ); $cart_table = "CREATE TABLE {$wpdb->prefix}tutor_carts ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, @@ -1067,6 +1050,45 @@ public static function create_database() { dbDelta( $customer_table ); } + /** + * Get coupon usage table schema. + * + * @since 4.2.0 + * + * @param bool $include_constraints Whether to include FOREIGN KEY constraints (for fresh installs). + * + * @return string + */ + public static function get_coupon_usage_table_schema( bool $include_constraints = true ): string { + global $wpdb; + + $charset_collate = $wpdb->get_charset_collate(); + $status_consumed = 'consumed'; + + $constraints = ''; + if ( $include_constraints ) { + $constraints = ", + CONSTRAINT fk_tutor_coupon_usage_coupon_code FOREIGN KEY (coupon_code) REFERENCES {$wpdb->prefix}tutor_coupons(coupon_code) ON DELETE CASCADE, + CONSTRAINT fk_tutor_coupon_usage_user_id FOREIGN KEY (user_id) REFERENCES {$wpdb->prefix}users(ID) ON DELETE CASCADE"; + } + + return "CREATE TABLE {$wpdb->prefix}tutor_coupon_usages ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + coupon_code VARCHAR(50) NOT NULL, + user_id BIGINT(20) UNSIGNED NOT NULL, + order_id BIGINT(20) UNSIGNED DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT '{$status_consumed}', + expires_at_gmt DATETIME DEFAULT NULL, + created_at_gmt DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY coupon_order (coupon_code, order_id), + KEY coupon_code (coupon_code), + KEY user_id (user_id), + KEY order_id (order_id), + KEY status (status){$constraints} + ) {$charset_collate};"; + } + /** * Manage tutor roles & permission * diff --git a/classes/Upgrader.php b/classes/Upgrader.php index 287b536c98..35f863142a 100644 --- a/classes/Upgrader.php +++ b/classes/Upgrader.php @@ -13,7 +13,6 @@ use Tutor\Ecommerce\CartController; use Tutor\Ecommerce\CheckoutController; use Tutor\Helpers\QueryHelper; -use Tutor\Models\CouponModel; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -402,25 +401,8 @@ public function upgrade_to_4_2_0() { $table_name = "{$wpdb->prefix}tutor_coupon_usages"; if ( QueryHelper::table_exists( $table_name ) ) { - $charset_collate = $wpdb->get_charset_collate(); - $coupon_usage_table = "CREATE TABLE {$table_name} ( - id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, - coupon_code VARCHAR(50) NOT NULL, - user_id BIGINT(20) UNSIGNED NOT NULL, - order_id BIGINT(20) UNSIGNED DEFAULT NULL, - status VARCHAR(20) NOT NULL DEFAULT '" . CouponModel::STATUS_CONSUMED . "', - expires_at_gmt DATETIME DEFAULT NULL, - created_at_gmt DATETIME NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY coupon_order (coupon_code, order_id), - KEY coupon_code (coupon_code), - KEY user_id (user_id), - KEY order_id (order_id), - KEY status (status) - ) $charset_collate;"; - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta( $coupon_usage_table ); + dbDelta( Tutor::get_coupon_usage_table_schema( false ) ); update_option( 'tutor_coupon_table_updated', true, false ); } From bc5cd116238e5bc27c47bcecb756e562a14afbd4 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 23 Oct 2026 12:37:03 +0600 Subject: [PATCH 202/218] Enhance Q&A access control and update REST API permission handling - Improved the `has_qna_access` method in `Q_And_A` class to enforce course content rules, ensuring students can only access Q&A for published or private courses. - Updated `RestAPI` to change permission callbacks from `__return_true` to `permission_optional_access_token`, enhancing security and access control for course-related endpoints. - Revised API documentation to reflect the new permission handling and clarify authentication requirements for public and private courses. --- classes/Q_And_A.php | 35 ++++++++++++++++--- classes/RestAPI.php | 4 +-- docs/api/Courses/course-rating-{id}.bru | 3 +- docs/api/Courses/courses.bru | 3 +- .../Instructors/author-information-{id}.bru | 2 +- restapi/RestAuth.php | 27 ++++++++++++++ 6 files changed, 64 insertions(+), 10 deletions(-) diff --git a/classes/Q_And_A.php b/classes/Q_And_A.php index caa9d85441..c817503ce7 100644 --- a/classes/Q_And_A.php +++ b/classes/Q_And_A.php @@ -12,8 +12,10 @@ defined( 'ABSPATH' ) || exit; +use Tutor\Core\Input; use Tutor\Helpers\QueryHelper; use Tutor\Helpers\UrlHelper; +use Tutor\Models\CourseModel; use Tutor\Models\EnrollmentModel; use Tutor\Traits\JsonResponse; /** @@ -123,7 +125,12 @@ public function add_learning_area_menu( $menu_items, $base_url ) { /** * Check user has access to QnA. * + * Matches learning-area course content rules: students may use Q&A on + * publish/private courses only. Draft, pending, trash, future, etc. + * require edit or IA content access — enrollment alone is not enough. + * * @since 2.6.1 + * @since 4.2.0 Gate by course post status. * * @param int $user_id user id. * @param int $course_id course id. @@ -131,13 +138,31 @@ public function add_learning_area_menu( $menu_items, $base_url ) { * @return boolean */ public static function has_qna_access( $user_id, $course_id ) { + $course_id = absint( $course_id ); + $user_id = absint( $user_id ); + + if ( ! $course_id ) { + return false; + } + + $status = get_post_status( $course_id ); + + // Non-publish / non-private: editors and IA content access only (not enrollment). + if ( ! in_array( $status, array( CourseModel::STATUS_PUBLISH, CourseModel::STATUS_PRIVATE ), true ) ) { + if ( ! $user_id ) { + return false; + } + + return tutor_utils()->can_user_edit_course( $user_id, $course_id ) + || (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + } + $is_public_course = Course_List::is_public( $course_id ); - $has_access = $is_public_course - || User::is_admin() - || tutor_utils()->is_instructor_of_this_course( $user_id, $course_id ) - || EnrollmentModel::is_enrolled( $course_id, $user_id ); - return $has_access; + return $is_public_course + || User::is_admin( $user_id ) + || tutor_utils()->is_instructor_of_this_course( $user_id, $course_id ) + || EnrollmentModel::is_enrolled( $course_id, $user_id ); } /** diff --git a/classes/RestAPI.php b/classes/RestAPI.php index b99daeb771..7347add2fc 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -318,7 +318,7 @@ public function init_routes() { $this->course_obj, 'course', ), - 'permission_callback' => '__return_true', + 'permission_callback' => array( RestAuth::class, 'permission_optional_access_token' ), ) ); @@ -488,7 +488,7 @@ public function init_routes() { 'args' => array( 'id' => $this->get_id_arg_schema(), ), - 'permission_callback' => '__return_true', + 'permission_callback' => array( RestAuth::class, 'permission_optional_access_token' ), ) ); diff --git a/docs/api/Courses/course-rating-{id}.bru b/docs/api/Courses/course-rating-{id}.bru index ffc86b349f..6e4a0f9008 100644 --- a/docs/api/Courses/course-rating-{id}.bru +++ b/docs/api/Courses/course-rating-{id}.bru @@ -29,7 +29,8 @@ docs { - **Route:** `/wp-json/tutor/v1/course-rating/:id` ## Authentication - - **Public** (`__return_true`) for **published** courses. Bearer JWT optional. + - **Public** (`permission_optional_access_token`) for **published** courses. Bearer JWT optional. + - Present but invalid access token → `rest_invalid_access_token` (does not fall back to guest data). - **Private** courses: requires a valid access token and enrolled / course-content access (same as plugin UI). Otherwise `401`/`403` from the global course privacy gate (or `404` from the rating handler if reached without access). - Guests get a reduced review payload (display name, content, date, rating only). - Authenticated callers get the full rating object (including `rating_sum`), full approved review rows (`comment_author_email` omitted unless the caller may view private user fields), plus `my_rating` (own review with status `approved` or `hold`, or `null`). diff --git a/docs/api/Courses/courses.bru b/docs/api/Courses/courses.bru index dfd5a01b02..6335e2fdce 100644 --- a/docs/api/Courses/courses.bru +++ b/docs/api/Courses/courses.bru @@ -32,8 +32,9 @@ docs { - **Route:** `/wp-json/tutor/v1/courses` ## Authentication - - **Public:** anonymous access allowed (`__return_true`). + - **Public:** anonymous access allowed (`permission_optional_access_token`). - Bearer JWT optional. Guests receive a reduced catalog-card payload; authenticated callers receive a fuller post DTO. + - Present but invalid access token → `rest_invalid_access_token` (does not fall back to guest data). - When using a JWT, permission comes from the bound API key (`Read`, `Read/Write`, or `All`). ## Query Parameters diff --git a/docs/api/Instructors/author-information-{id}.bru b/docs/api/Instructors/author-information-{id}.bru index 58391d6308..3d6cef620d 100644 --- a/docs/api/Instructors/author-information-{id}.bru +++ b/docs/api/Instructors/author-information-{id}.bru @@ -25,7 +25,7 @@ docs { ## Authentication - **Public** when the user's public profile layout is not private (`permission_public_author`). - - Bearer JWT optional. + - Bearer JWT optional. Present but invalid access token → `rest_invalid_access_token`. ## Public vs private fields diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 5daa65adc2..d6bb8f56ca 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1522,11 +1522,33 @@ public static function is_public_profile_layout_open( $user_id ) { return 'private' !== $profile_layout; } + /** + * Permission: public route with optional access token. + * + * Empty token → allow (guest). Present but invalid token → reject. + * Used by catalog/list routes that do not need resource-specific gates. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return true|\WP_Error + */ + public static function permission_optional_access_token( WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $token_error = self::get_access_token_error( false ); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + return true; + } + /** * Permission: author card — same gate as public-profile.php. * * Open instructor/student layout → allow (anonymous or JWT). * Private layout → deny (JWT does not bypass). Unknown user → 404. + * Present but invalid access token → reject (optional auth). * * @since 4.2.0 * @@ -1535,6 +1557,11 @@ public static function is_public_profile_layout_open( $user_id ) { * @return bool|\WP_Error */ public static function permission_public_author( WP_REST_Request $request ) { + $token_error = self::get_access_token_error( false ); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + $user_id = absint( $request->get_param( 'id' ) ); if ( ! $user_id || ! get_userdata( $user_id ) ) { return self::invalid_resource_error( __( 'Author not found.', 'tutor' ) ); From 1ba79fa994979704c552a68213a8f7af64c4c099 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 23 Oct 2026 16:38:23 +0600 Subject: [PATCH 203/218] Enhance clipboard functionality with feedback notifications - Introduced `showCopyFeedback` function to provide user feedback via toast notifications or tooltips when copying and pasting text. - Updated clipboard copy and paste logic to utilize the new feedback mechanism, improving user experience. - Enhanced error handling for clipboard operations, ensuring users receive appropriate messages for success or failure scenarios. --- v2-library/src/js/popupMenu.js | 150 +++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 18 deletions(-) diff --git a/v2-library/src/js/popupMenu.js b/v2-library/src/js/popupMenu.js index 7e1fc95c8a..777415ad69 100644 --- a/v2-library/src/js/popupMenu.js +++ b/v2-library/src/js/popupMenu.js @@ -52,36 +52,106 @@ }); })(); +/** + * Show clipboard feedback via toast when available, otherwise tooltip. + * + * @param {HTMLElement} targetEl Click target for tooltip fallback. + * @param {string} title Toast title. + * @param {string} message Toast / tooltip message. + * @param {'success'|'error'|'warning'} [type='success'] Toast type. + * @return {void} + */ +const showCopyFeedback = (targetEl, title, message, type = 'success') => { + if (typeof window.tutor_toast === 'function') { + window.tutor_toast(title, message, type); + return; + } + + showToolTip(targetEl, message); +}; + /** * Copy to clipboard */ document.addEventListener('click', async (e) => { const btnTargetAttr = 'data-tutor-copy-target'; - if (e.target.hasAttribute(btnTargetAttr)) { - const id = e.target.getAttribute(btnTargetAttr); + const copyBtn = e.target.closest(`[${btnTargetAttr}]`); + if (!copyBtn) { + return; + } - /* Get the text field */ - const content = document.getElementById(id).textContent.trim(); + const { __ } = wp.i18n; + const id = copyBtn.getAttribute(btnTargetAttr); + + /* Get the text field */ + const content = document.getElementById(id).textContent.trim(); + + try { await copyToClipboard(content); if (content) { - showToolTip(e.target, 'Copied'); + showCopyFeedback( + copyBtn, + __('Copied!', 'tutor'), + __('Copied to clipboard', 'tutor'), + 'success', + ); } else { - showToolTip(e.target, 'Nothing Found!'); + showCopyFeedback( + copyBtn, + __('Failed', 'tutor'), + __('Nothing Found!', 'tutor'), + 'error', + ); } + } catch (err) { + showCopyFeedback( + copyBtn, + __('Failed', 'tutor'), + __('Failed to copy', 'tutor'), + 'error', + ); } }); -// Copy text to clipboard +/** + * Copy text to clipboard. + * + * Uses the Clipboard API when available (secure contexts), otherwise + * falls back to execCommand for HTTP / unsupported browsers. + * + * @param {string} text Text to copy. + * @return {Promise} + */ const copyToClipboard = (text) => { - return new Promise((resolve) => { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + + return new Promise((resolve, reject) => { const textArea = document.createElement('textarea'); textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; document.body.appendChild(textArea); + textArea.focus(); textArea.select(); - document.execCommand('copy'); - document.body.removeChild(textArea); - resolve(); + + try { + // Clipboard API is unavailable outside secure contexts; this is the only fallback. + // Cast avoids the deprecated Document.execCommand DOM typing while keeping HTTP support. + const legacyExecCommand = /** @type {(commandId: string) => boolean} */ ( + /** @type {Record} */ (document).execCommand + ); + legacyExecCommand.call(document, 'copy') + ? resolve() + : reject(new Error('Copy command failed')); + } catch (err) { + reject(err); + } finally { + textArea.remove(); + } }); }; @@ -101,30 +171,74 @@ const showToolTip = (targetEl, text = 'Copied!') => { document.addEventListener('click', async (e) => { const copyTargetAttr = 'data-tutor-clipboard-copy-target'; const pasteTargetAttr = 'data-tutor-clipboard-paste-target'; + const { __ } = wp.i18n; - if (e.target.hasAttribute(copyTargetAttr)) { - const id = e.target.getAttribute(copyTargetAttr); + const copyBtn = e.target.closest(`[${copyTargetAttr}]`); + if (copyBtn) { + const id = copyBtn.getAttribute(copyTargetAttr); /* Get the text field */ const text = document.getElementById(id).value; /* Copy text into clipboard */ if (text) { - await navigator.clipboard.writeText(text); - showToolTip(e.target, 'Copied'); + try { + await copyToClipboard(text); + showCopyFeedback( + copyBtn, + __('Copied!', 'tutor'), + __('Copied to clipboard', 'tutor'), + 'success', + ); + } catch (err) { + showCopyFeedback( + copyBtn, + __('Failed', 'tutor'), + __('Failed to copy', 'tutor'), + 'error', + ); + } } + return; + } + + const pasteBtn = e.target.closest(`[${pasteTargetAttr}]`); + if (!pasteBtn) { + return; } - if (e.target.hasAttribute(pasteTargetAttr)) { - const id = e.target.getAttribute(pasteTargetAttr); + if (!navigator.clipboard || !window.isSecureContext) { + showCopyFeedback( + pasteBtn, + __('Failed', 'tutor'), + __('Paste not supported', 'tutor'), + 'error', + ); + return; + } + + const id = pasteBtn.getAttribute(pasteTargetAttr); + try { const text = await navigator.clipboard.readText(); /* Pasting on the text field */ if (text) { document.getElementById(id).value = text; - showToolTip(e.target, 'Pasted'); + showCopyFeedback( + pasteBtn, + __('Success', 'tutor'), + __('Pasted', 'tutor'), + 'success', + ); } + } catch (err) { + showCopyFeedback( + pasteBtn, + __('Failed', 'tutor'), + __('Paste failed', 'tutor'), + 'error', + ); } }); From 4429c19dc7aa7602fd6b2ccdf02bdc148f78d186 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 23 Oct 2026 16:49:58 +0600 Subject: [PATCH 204/218] Refactor Q&A access control logic in `has_qna_access` method - Removed redundant checks and streamlined the logic to determine user access to Q&A based on course visibility. - Simplified the method by eliminating unnecessary variable assignments and conditions, enhancing code readability and maintainability. - Updated comments to reflect the current functionality and clarify access rules for Q&A based on course status. --- classes/Q_And_A.php | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/classes/Q_And_A.php b/classes/Q_And_A.php index c817503ce7..a53ede1e0e 100644 --- a/classes/Q_And_A.php +++ b/classes/Q_And_A.php @@ -15,7 +15,6 @@ use Tutor\Core\Input; use Tutor\Helpers\QueryHelper; use Tutor\Helpers\UrlHelper; -use Tutor\Models\CourseModel; use Tutor\Models\EnrollmentModel; use Tutor\Traits\JsonResponse; /** @@ -125,12 +124,7 @@ public function add_learning_area_menu( $menu_items, $base_url ) { /** * Check user has access to QnA. * - * Matches learning-area course content rules: students may use Q&A on - * publish/private courses only. Draft, pending, trash, future, etc. - * require edit or IA content access — enrollment alone is not enough. - * * @since 2.6.1 - * @since 4.2.0 Gate by course post status. * * @param int $user_id user id. * @param int $course_id course id. @@ -138,25 +132,6 @@ public function add_learning_area_menu( $menu_items, $base_url ) { * @return boolean */ public static function has_qna_access( $user_id, $course_id ) { - $course_id = absint( $course_id ); - $user_id = absint( $user_id ); - - if ( ! $course_id ) { - return false; - } - - $status = get_post_status( $course_id ); - - // Non-publish / non-private: editors and IA content access only (not enrollment). - if ( ! in_array( $status, array( CourseModel::STATUS_PUBLISH, CourseModel::STATUS_PRIVATE ), true ) ) { - if ( ! $user_id ) { - return false; - } - - return tutor_utils()->can_user_edit_course( $user_id, $course_id ) - || (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); - } - $is_public_course = Course_List::is_public( $course_id ); return $is_public_course From 2ca1add9d95ef9d561052d55118a89272dbf325c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 23 Oct 2026 16:54:24 +0600 Subject: [PATCH 205/218] Revert Q_And_A has_qna_access to leave post-status gating in REST only. Co-authored-by: Cursor --- classes/Q_And_A.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/classes/Q_And_A.php b/classes/Q_And_A.php index a53ede1e0e..caa9d85441 100644 --- a/classes/Q_And_A.php +++ b/classes/Q_And_A.php @@ -12,7 +12,6 @@ defined( 'ABSPATH' ) || exit; -use Tutor\Core\Input; use Tutor\Helpers\QueryHelper; use Tutor\Helpers\UrlHelper; use Tutor\Models\EnrollmentModel; @@ -134,10 +133,11 @@ public function add_learning_area_menu( $menu_items, $base_url ) { public static function has_qna_access( $user_id, $course_id ) { $is_public_course = Course_List::is_public( $course_id ); - return $is_public_course - || User::is_admin( $user_id ) - || tutor_utils()->is_instructor_of_this_course( $user_id, $course_id ) - || EnrollmentModel::is_enrolled( $course_id, $user_id ); + $has_access = $is_public_course + || User::is_admin() + || tutor_utils()->is_instructor_of_this_course( $user_id, $course_id ) + || EnrollmentModel::is_enrolled( $course_id, $user_id ); + return $has_access; } /** From b95cea50ad5ff42d051284b450d7e1ac0c2db1bc Mon Sep 17 00:00:00 2001 From: Sazedul Haque Date: Fri, 23 Oct 2026 17:01:09 +0600 Subject: [PATCH 206/218] Resume quizzes at the first unanswered question Find the earliest unanswered question when resuming an attempt instead of advancing past the last answered question. Apply the same behavior in the learning area and single-quiz view. --- templates/learning-area/quiz/attempt.php | 13 +++++++------ templates/single/quiz/body.php | 13 +++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/templates/learning-area/quiz/attempt.php b/templates/learning-area/quiz/attempt.php index 43e5353f97..6710891d8d 100644 --- a/templates/learning-area/quiz/attempt.php +++ b/templates/learning-area/quiz/attempt.php @@ -91,7 +91,7 @@ $committed_attempt_answers = QuizModel::get_committed_answers_by_attempt( (int) $tutor_is_started_quiz->attempt_id ); -$last_answered_index = 0; +$first_unanswered_index = 0; $default_values = array( 'attempt[' . $tutor_is_started_quiz->attempt_id . '][quiz_question_ids][]' => array_map( @@ -104,15 +104,16 @@ function ( $question ) { if ( is_array( $questions ) ) { foreach ( $questions as $question_index => $question ) { - if ( isset( $committed_attempt_answers[ (int) $question->question_id ] ) ) { - $last_answered_index = $question_index + 1; + if ( ! isset( $committed_attempt_answers[ (int) $question->question_id ] ) ) { + $first_unanswered_index = $question_index + 1; + break; } } } -$start_index = $last_answered_index > 0 - ? min( count( $questions ), $last_answered_index + 1 ) - : 1; +$start_index = $first_unanswered_index > 0 + ? $first_unanswered_index + : max( 1, count( $questions ) ); ?>
attempt_id ); - $last_answered_index = 0; + $first_unanswered_index = 0; if ( is_array( $questions ) ) { foreach ( $questions as $question_index => $question ) { - if ( isset( $committed_attempt_answers[ (int) $question->question_id ] ) ) { - $last_answered_index = $question_index + 1; + if ( ! isset( $committed_attempt_answers[ (int) $question->question_id ] ) ) { + $first_unanswered_index = $question_index + 1; + break; } } } - $start_question_index = $last_answered_index > 0 - ? min( count( $questions ), $last_answered_index + 1 ) - : 1; + $start_question_index = $first_unanswered_index > 0 + ? $first_unanswered_index + : max( 1, count( $questions ) ); /* Quiz Meta */ require __DIR__ . '/parts/meta.php'; From dce2f3cb2f14cb67056272143b1cb5c9d86ecb07 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:20:19 +0600 Subject: [PATCH 207/218] Refactor coupon usage count query --- models/CouponModel.php | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/models/CouponModel.php b/models/CouponModel.php index 83d249405e..c1313674d6 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -513,16 +513,17 @@ public function get_coupon_usage_count( $coupon_code, int $exclude_order_id = 0 $query = DB::table( $this->coupon_usage_table ) ->where( 'coupon_code', $coupon_code ) - ->where_raw( - '(status IS NULL OR status = %s OR (status = %s AND (expires_at_gmt IS NULL OR expires_at_gmt > %s)))', - array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) - ); + ->where_raw( ' ( status = %s OR ( status = %s AND expires_at_gmt > %s ) ) ', array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) ); if ( $exclude_order_id > 0 ) { - $query->where_raw( '(order_id IS NULL OR order_id != %d)', array( $exclude_order_id ) ); + $query->where_raw( 'order_id != %d', array( $exclude_order_id ) ); } - return $query->count(); + $count = $query->count(); + + $last_query = DB::get_last_query(); + + return $count; } /** @@ -543,13 +544,10 @@ public function get_user_usage_count( $coupon_code, $user_id, int $exclude_order $query = DB::table( $this->coupon_usage_table ) ->where( 'coupon_code', $coupon_code ) ->where( 'user_id', $user_id ) - ->where_raw( - '(status IS NULL OR status = %s OR (status = %s AND (expires_at_gmt IS NULL OR expires_at_gmt > %s)))', - array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) - ); + ->where_raw( ' ( status = %s OR ( status = %s AND expires_at_gmt > %s ) ) ', array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) ); if ( $exclude_order_id > 0 ) { - $query->where_raw( '(order_id IS NULL OR order_id != %d)', array( $exclude_order_id ) ); + $query->where_raw( 'order_id != %d', array( $exclude_order_id ) ); } return $query->count(); From 468db9d4b59ee6d4ef662d670934c8bd4be8aac9 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:06:20 +0600 Subject: [PATCH 208/218] User id check added on the has_user_usage_limit --- models/CouponModel.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/models/CouponModel.php b/models/CouponModel.php index c1313674d6..584224c753 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -519,11 +519,7 @@ public function get_coupon_usage_count( $coupon_code, int $exclude_order_id = 0 $query->where_raw( 'order_id != %d', array( $exclude_order_id ) ); } - $count = $query->count(); - - $last_query = DB::get_last_query(); - - return $count; + return $query->count(); } /** @@ -1083,7 +1079,7 @@ public function has_user_usage_limit( object $coupon, int $user_id, int $exclude } } - if ( $user_usage_limit > 0 ) { + if ( $user_usage_limit > 0 && $user_id ) { $user_usage_count = $this->get_user_usage_count( $coupon->coupon_code, $user_id, $exclude_order_id ); if ( $user_usage_count >= $user_usage_limit ) { $has_limit = false; From ce9b5f8c3494a5caea564e137f52c3e1879f317c Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:35:10 +0600 Subject: [PATCH 209/218] Clear coupon reservation on Order trash or cancelled --- ecommerce/HooksHandler.php | 45 +++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/ecommerce/HooksHandler.php b/ecommerce/HooksHandler.php index 44a5a15158..2d65b0f8cd 100644 --- a/ecommerce/HooksHandler.php +++ b/ecommerce/HooksHandler.php @@ -16,6 +16,7 @@ use Tutor\Models\CartModel; use Tutor\Models\OrderModel; use Tutor\Helpers\QueryHelper; +use Tutor\Models\CouponModel; use Tutor\Models\EnrollmentModel; use Tutor\Models\OrderMetaModel; use Tutor\Models\OrderActivitiesModel; @@ -95,6 +96,7 @@ public function __construct() { */ add_action( 'tutor_order_placed', array( $this, 'store_billing_address_for_order' ) ); add_action( 'tutor_order_updated', array( $this, 'store_billing_address_for_order' ) ); + add_action( 'tutor_order_updated', array( $this, 'maybe_clear_coupon_reserve' ) ); } /** @@ -448,23 +450,21 @@ public function manage_earnings_and_enrollments( string $order_status, int $orde do_action( 'tutor_order_enrolled', $order, $has_enrollment->ID ); } } - } else { - if ( $order->order_status === $this->order_model::ORDER_COMPLETED ) { + } elseif ( $order->order_status === $this->order_model::ORDER_COMPLETED ) { // Insert enrollment. - add_filter( 'tutor_enroll_data', fn( $enroll_data) => array_merge( $enroll_data, array( 'post_status' => 'completed' ) ) ); + add_filter( 'tutor_enroll_data', fn( $enroll_data ) => array_merge( $enroll_data, array( 'post_status' => 'completed' ) ) ); $enrollment_id = EnrollmentModel::do_enroll( $object_id, $order_id, $student_id ); - if ( $enrollment_id ) { - if ( $this->is_bundle_order( $order, $object_id ) && $this->order_model->is_single_order( $order ) ) { - BundleModel::enroll_to_bundle_courses( $object_id, $student_id ); - } - update_post_meta( $enrollment_id, EnrollmentModel::ENROLLMENT_ORDER_ID_META, $order_id ); - - do_action( 'tutor_order_enrolled', $order, $enrollment_id ); - } else { - // Log error message with student id and course id. - error_log( "Error updating enrollment for student {$student_id} and course {$object_id}" ); + if ( $enrollment_id ) { + if ( $this->is_bundle_order( $order, $object_id ) && $this->order_model->is_single_order( $order ) ) { + BundleModel::enroll_to_bundle_courses( $object_id, $student_id ); } + update_post_meta( $enrollment_id, EnrollmentModel::ENROLLMENT_ORDER_ID_META, $order_id ); + + do_action( 'tutor_order_enrolled', $order, $enrollment_id ); + } else { + // Log error message with student id and course id. + error_log( "Error updating enrollment for student {$student_id} and course {$object_id}" ); } } } @@ -614,4 +614,23 @@ public function store_billing_address_for_order( array $order_data ) { $meta_value ); } + + /** + * Maybe clear coupon reservation + * + * @since 4.2.0 + * + * @param array $order_data Order data. + * + * @return void + */ + public function maybe_clear_coupon_reserve( array $order_data ): void { + $updated_order_status = $order_data['order_status'] ?? ''; + $order_id = (int) $order_data['id'] ?? 0; + + $clearable_status = array( OrderModel::ORDER_CANCELLED, OrderModel::ORDER_TRASH ); + if ( $order_id && in_array( $updated_order_status, $clearable_status, true ) ) { + $this->coupon_ctrl->cancel_coupon_usage( $order_id ); + } + } } From a0170298438599ec9bdf733707e1d754964583c1 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:45:36 +0600 Subject: [PATCH 210/218] Removed the expire date update --- models/CouponModel.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/models/CouponModel.php b/models/CouponModel.php index 584224c753..7d46a67e15 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -1274,8 +1274,7 @@ public function reserve_coupon_usage( int $order_id, string $coupon_code, int $u ->where( 'id', $existing->id ) ->update( array( - 'status' => $update_status, - 'expires_at_gmt' => $expires_gmt, + 'status' => $update_status, ) ); } else { From 45e73e3e4c4cb0cd7f491575fe65994e9b3934be Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:10 +0600 Subject: [PATCH 211/218] Code refactor --- ecommerce/CheckoutController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecommerce/CheckoutController.php b/ecommerce/CheckoutController.php index 88100028da..031e03fc92 100644 --- a/ecommerce/CheckoutController.php +++ b/ecommerce/CheckoutController.php @@ -764,7 +764,7 @@ public function pay_now() { } if ( ! empty( $order_data ) ) { - $placed_order_id = is_array( $order_data ) && isset( $order_data['id'] ) ? (int) ( $order_data['id'] ?? 0 ) : 0; + $placed_order_id = (int) is_array( $order_data ) && isset( $order_data['id'] ) ? $order_data['id'] : 0; $coupon_code = is_array( $order_data ) && isset( $order_data['coupon_code'] ) ? $order_data['coupon_code'] : $checkout_data->coupon_code; if ( $placed_order_id && ! empty( $coupon_code ) && $this->coupon_model->is_reservation_required( $coupon_code ) ) { $reserved = $this->coupon_model->reserve_coupon_usage( $placed_order_id, $coupon_code, (int) $current_user_id, 30 ); From ab21e5f36acda66168f37a7437d2c22e0597b484 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:34:48 +0600 Subject: [PATCH 212/218] Refactor coupon usage & user usage check methods --- classes/Config.php | 2 ++ classes/Tutor.php | 1 + models/CouponModel.php | 62 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/classes/Config.php b/classes/Config.php index 8b6847d122..0008dc130d 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -10,6 +10,8 @@ namespace TUTOR; +use Tutor\Core\Singleton; + defined( 'ABSPATH' ) || exit; /** diff --git a/classes/Tutor.php b/classes/Tutor.php index c794601f74..b2cb75d780 100644 --- a/classes/Tutor.php +++ b/classes/Tutor.php @@ -10,6 +10,7 @@ namespace TUTOR; +use Tutor\Core\Singleton; use Tutor\Models\CourseModel; use Tutor\Ecommerce\Ecommerce; use Tutor\GDPR\GDPR; diff --git a/models/CouponModel.php b/models/CouponModel.php index 7d46a67e15..574772bb03 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -510,16 +510,41 @@ public function get_coupon_count( $where = array(), string $search_term = '' ) { */ public function get_coupon_usage_count( $coupon_code, int $exclude_order_id = 0 ) { $now_gmt = current_time( 'mysql', true ); + $count = 0; - $query = DB::table( $this->coupon_usage_table ) + $user_id = get_current_user_id(); + + $results = DB::table( $this->coupon_usage_table ) ->where( 'coupon_code', $coupon_code ) - ->where_raw( ' ( status = %s OR ( status = %s AND expires_at_gmt > %s ) ) ', array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) ); + ->get(); + + foreach ( $results as $result ) { + // Check if the table schema is 4.2.0+. + if ( property_exists( $result, 'order_id' ) && $result->order_id > 0 ) { + $order_id = (int) $result->order_id; + $status = $result->status; + $expires_at_gmt = $result->expires_at_gmt; + + // Continue for the exclude order. + if ( $order_id === $exclude_order_id ) { + continue; + } + + // If the reserver is the current user and the reservation is not expired then continue and allow them to use the coupon. + if ( $user_id === (int) $result->user_id && $expires_at_gmt > $now_gmt ) { + continue; + } - if ( $exclude_order_id > 0 ) { - $query->where_raw( 'order_id != %d', array( $exclude_order_id ) ); + // Considering the reserved as used until not expired. + if ( self::STATUS_RESERVED === $status && $expires_at_gmt > $now_gmt ) { + ++$count; + } + } else { + ++$count; + } } - return $query->count(); + return $count; } /** @@ -535,18 +560,33 @@ public function get_coupon_usage_count( $coupon_code, int $exclude_order_id = 0 * @return int */ public function get_user_usage_count( $coupon_code, $user_id, int $exclude_order_id = 0 ) { - $now_gmt = current_time( 'mysql', true ); + $count = 0; - $query = DB::table( $this->coupon_usage_table ) + $results = DB::table( $this->coupon_usage_table ) ->where( 'coupon_code', $coupon_code ) ->where( 'user_id', $user_id ) - ->where_raw( ' ( status = %s OR ( status = %s AND expires_at_gmt > %s ) ) ', array( self::STATUS_CONSUMED, self::STATUS_RESERVED, $now_gmt ) ); + ->get(); + + foreach ( $results as $result ) { + // Check if the table schema is 4.2.0+. + if ( property_exists( $result, 'order_id' ) && $result->order_id > 0 ) { + $order_id = (int) $result->order_id; + $status = $result->status; + + // Continue for the exclude order. + if ( $order_id === $exclude_order_id ) { + continue; + } - if ( $exclude_order_id > 0 ) { - $query->where_raw( 'order_id != %d', array( $exclude_order_id ) ); + if ( self::STATUS_CONSUMED === $status ) { + ++$count; + } + } else { + ++$count; + } } - return $query->count(); + return $count; } /** From 88f524c516769c3ad245b42e00f7a02fae220210 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:38:34 +0600 Subject: [PATCH 213/218] Refactor coupon_usages table migration --- classes/Upgrader.php | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/classes/Upgrader.php b/classes/Upgrader.php index 35f863142a..3f44436271 100644 --- a/classes/Upgrader.php +++ b/classes/Upgrader.php @@ -391,21 +391,9 @@ public function install_gradebook() { * @return void */ public function upgrade_to_4_2_0() { - $is_coupon_table_updated = get_option( 'tutor_coupon_table_updated', false ); - if ( $is_coupon_table_updated ) { - return; - } - - if ( version_compare( TUTOR_VERSION, '4.2.0', '>=' ) ) { - global $wpdb; - - $table_name = "{$wpdb->prefix}tutor_coupon_usages"; - if ( QueryHelper::table_exists( $table_name ) ) { - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta( Tutor::get_coupon_usage_table_schema( false ) ); - - update_option( 'tutor_coupon_table_updated', true, false ); - } + if ( QueryHelper::table_exists( 'tutor_coupon_usages' ) && ! QueryHelper::column_exist( 'tutor_coupon_usages', 'order_id' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( Tutor::get_coupon_usage_table_schema( false ) ); } } From 509920589b2b2ceff01b296b063903b676d9aadb Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:54:15 +0600 Subject: [PATCH 214/218] Release date updated --- readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index e36712995e..430db80fa1 100644 --- a/readme.txt +++ b/readme.txt @@ -338,7 +338,7 @@ Tutor LMS allows you to offer certificates to your students upon course completi == Changelog == -= 4.2.0 - 22 Oct, 2026 += 4.2.0 - 27 Oct, 2026 New: Implemented REST API authentication system with personalized access token. New: Added authorization for each endpoints to ensure user capability wise operation. From ceccb153a335f9bff9aa9404727c73416014a30b Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:40:54 +0600 Subject: [PATCH 215/218] Fix: Coupon usage limit is not working --- models/CouponModel.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/models/CouponModel.php b/models/CouponModel.php index 574772bb03..12697fb2aa 100644 --- a/models/CouponModel.php +++ b/models/CouponModel.php @@ -530,14 +530,18 @@ public function get_coupon_usage_count( $coupon_code, int $exclude_order_id = 0 continue; } - // If the reserver is the current user and the reservation is not expired then continue and allow them to use the coupon. - if ( $user_id === (int) $result->user_id && $expires_at_gmt > $now_gmt ) { - continue; - } - - // Considering the reserved as used until not expired. - if ( self::STATUS_RESERVED === $status && $expires_at_gmt > $now_gmt ) { + if ( self::STATUS_CONSUMED === $status ) { ++$count; + } elseif ( self::STATUS_RESERVED === $status ) { + // If the reserver is the current user and the reservation is not expired then continue and allow them to use the coupon. + if ( $user_id === (int) $result->user_id && $expires_at_gmt > $now_gmt ) { + continue; + } + + // Considering the reserved as used until not expired. + if ( $expires_at_gmt > $now_gmt ) { + ++$count; + } } } else { ++$count; From f845e9d8d61bc1ef3f5e203d6bafe0acd884066c Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:41:38 +0600 Subject: [PATCH 216/218] Unittest added for the CouponModel --- phpunit.xml.dist | 3 + tests/phpunit/CouponModelTest.php | 179 ++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/phpunit/CouponModelTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6d968ece60..dc3e63d94b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -33,5 +33,8 @@ tests/phpunit/CourseTest.php + + tests/phpunit/CouponModelTest.php + diff --git a/tests/phpunit/CouponModelTest.php b/tests/phpunit/CouponModelTest.php new file mode 100644 index 0000000000..7f00635698 --- /dev/null +++ b/tests/phpunit/CouponModelTest.php @@ -0,0 +1,179 @@ +coupon_model = new CouponModel(); + + $this->create_tables(); + } + + public function test_get_coupon_usage_count_before_4_2_0() { + $user_id = $this->factory->user->create(); + + $coupon = $this->get_coupon_data( $user_id ); + + try { + $this->create_coupon( $coupon ); + } catch ( \Throwable $th ) { + $this->fail( 'Failed to create coupon: ' . $th->getMessage() ); + return; + } + + $usage_data = array( + 'coupon_code' => $coupon['coupon_code'], + 'user_id' => $user_id, + 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + ); + + $store = $this->coupon_model->store_coupon_usage( $usage_data ); + + $this->assertNotEmpty( $store ); + + $actual = (int) $this->coupon_model->get_coupon_usage_count( $coupon['coupon_code'] ); + $expected = 1; + + $this->assertSame( $expected, $actual ); + + $store = $this->coupon_model->store_coupon_usage( $usage_data ); + $this->assertNotEmpty( $store ); + + $actual = (int) $this->coupon_model->get_coupon_usage_count( $coupon['coupon_code'] ); + $expected = 2; + + $this->assertSame( $expected, $actual ); + } + + public function test_get_coupon_usage_count_reservation() { + $now_gmt = current_time( 'mysql', true ); + $expires_gmt = gmdate( 'Y-m-d H:i:s', strtotime( '+30 minutes', strtotime( $now_gmt ) ) ); + + $user_id = $this->factory->user->create(); + $order_id = 1; + $coupon_data = $this->get_coupon_data( $user_id ); + + $coupon_data['total_usage_limit'] = 1; + $coupon_data['coupon_code'] = 'USAGE1'; + + $this->assertNotEmpty( $this->create_coupon( $coupon_data ) ); + + $usage = array( + 'coupon_code' => $coupon_data['coupon_code'], + 'order_id' => $order_id, + 'user_id' => $user_id, + 'status' => $this->coupon_model::STATUS_RESERVED, + 'expires_at_gmt' => $expires_gmt, + ); + + $usage_id = $this->coupon_model->store_coupon_usage( $usage ); + $this->assertNotEmpty( $usage_id ); + + $this->assertSame( 1, (int) $this->coupon_model->get_coupon_usage_count( $coupon_data['coupon_code'] ) ); + + // Another usage should return false. + $another_user = $this->factory->user->create(); + $is_valid = $this->coupon_model->is_coupon_valid( (object) $coupon_data, $another_user ); + $this->assertFalse( $is_valid ); + } + + /** + * Create coupons and coupon usage tables. + * + * @return void + */ + private function create_tables(): void { + global $wpdb; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset_collate = $wpdb->get_charset_collate(); + + // 1. Create coupons table first as per table schema. + $coupons_table = "CREATE TABLE {$wpdb->prefix}tutor_coupons ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + coupon_status VARCHAR(50), + coupon_type VARCHAR(100) DEFAULT 'code', + coupon_code VARCHAR(50) NOT NULL, + coupon_title VARCHAR(255) NOT NULL, + coupon_description TEXT, + discount_type ENUM('percentage', 'flat') NOT NULL, + discount_amount DECIMAL(13, 2) NOT NULL, + applies_to VARCHAR(100) DEFAULT 'all_courses_and_bundles', + total_usage_limit INT(10) UNSIGNED DEFAULT NULL, + per_user_usage_limit TINYINT(4) UNSIGNED DEFAULT NULL, + purchase_requirement VARCHAR(50) DEFAULT 'no_minimum', + purchase_requirement_value DECIMAL(13, 2), + start_date_gmt DATETIME NOT NULL, + expire_date_gmt DATETIME DEFAULT NULL, + created_at_gmt DATETIME NOT NULL, + created_by BIGINT(20) UNSIGNED NOT NULL, + updated_at_gmt DATETIME, + updated_by BIGINT(20) UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY coupon_code (coupon_code), + KEY start_date_gmt (start_date_gmt), + KEY expire_date_gmt (expire_date_gmt) + ) $charset_collate;"; + + dbDelta( $coupons_table ); + + // 2. Create coupon usages table after tutor_coupons exists. + $coupon_usage_table = Tutor::get_coupon_usage_table_schema( false ); + dbDelta( $coupon_usage_table ); + } + + /** + * Drop tables created for the tests. + * + * @return void + */ + private function drop_tables(): void { + global $wpdb; + + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}tutor_coupon_usages" ); + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}tutor_coupons" ); + } + + private function create_coupon( array $coupon_data ) { + return $this->coupon_model->create_coupon( $coupon_data ); + } + + private function get_coupon_data( int $user_id = 1 ) { + return array( + 'coupon_status' => 'active', + 'coupon_type' => 'code', + 'coupon_code' => 'SUMMER50', + 'coupon_title' => 'Summer Special 50% Off', + 'coupon_description' => 'Get a 50% discount on all courses and bundles.', + 'discount_type' => 'percentage', + 'discount_amount' => 50.00, + 'applies_to' => 'all_courses_and_bundles', + 'total_usage_limit' => 100, + 'per_user_usage_limit' => 1, + 'purchase_requirement' => 'minimum_purchase', + 'purchase_requirement_value' => 20.00, + 'start_date_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'expire_date_gmt' => gmdate( 'Y-m-d H:i:s', strtotime( '+30 days' ) ), + 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'created_by' => $user_id, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'updated_by' => $user_id, + ); + } + + public function tear_down(): void { + $this->drop_tables(); + + unset( $this->coupon_model ); + + parent::tear_down(); + } +} From b656dc375f503e5598f7238a591e1c5a1faee2f4 Mon Sep 17 00:00:00 2001 From: shewa <30765517+shewa12@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:22:47 +0600 Subject: [PATCH 217/218] REST API content updated --- classes/Tools_V2.php | 2 +- views/pages/tools/manage-tokens.php | 22 ++++++++++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/classes/Tools_V2.php b/classes/Tools_V2.php index 1fc2c3019c..89760db61c 100644 --- a/classes/Tools_V2.php +++ b/classes/Tools_V2.php @@ -327,7 +327,7 @@ private function get_tools_fields() { ), ), 'tutor_rest_api' => array( - 'label' => __( 'Rest API', 'tutor' ), + 'label' => __( 'REST API', 'tutor' ), 'slug' => 'tutor_rest_api', 'desc' => __( 'Token List', 'tutor' ), 'template' => 'manage-tokens', diff --git a/views/pages/tools/manage-tokens.php b/views/pages/tools/manage-tokens.php index 683f631d71..780950f4c6 100644 --- a/views/pages/tools/manage-tokens.php +++ b/views/pages/tools/manage-tokens.php @@ -32,7 +32,7 @@ ?>
- +
@@ -54,11 +54,10 @@
@@ -85,11 +84,10 @@ class="tutor-form-control tutor-w-160"
@@ -109,7 +107,7 @@ class="tutor-form-control tutor-w-160"
- +
@@ -213,7 +211,7 @@ class="tutor-form-control tutor-w-160"
- +
@@ -221,7 +219,7 @@ class="tutor-form-control tutor-w-160"
- +