diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..b3bf2be848 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +**ClickUp ticket link:** + +## Description + + + +## Testing + +* [ ] Tested +* [ ] Not tested + +## Use of AI Tool + +**Used:** Yes / No + +**Used for:** + + diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml new file mode 100644 index 0000000000..5f7328810a --- /dev/null +++ b/.github/workflows/pr-description.yml @@ -0,0 +1,79 @@ +name: Validate PR Description + +on: +pull_request: +types: [opened, edited, synchronize, reopened] + +permissions: +pull-requests: read + +jobs: +validate: +runs-on: ubuntu-latest +steps: +- name: Validate PR description +env: +PR_BODY: ${{ github.event.pull_request.body }} +shell: bash +run: | +set -euo pipefail + +``` + errors=0 + + # Check required sections and fields. + required_patterns=( + '^\*\*ClickUp ticket link:\*\*' + '^## Description' + '^## Testing' + '^- \[ \] Tested$' + '^- \[ \] Not tested$' + '^## Use of AI Tool' + '^\*\*Used:\*\* (Yes|No)$' + '^\*\*Used for:\*\*' + ) + + for pattern in "${required_patterns[@]}"; do + if ! grep -Eq "$pattern" <<< "$PR_BODY"; then + echo "::error::Missing or invalid PR description field: $pattern" + errors=1 + fi + done + + # Ensure exactly one testing option is selected. + tested_count=$(grep -Ec '^- \[[xX]\] Tested$' <<< "$PR_BODY" || true) + not_tested_count=$(grep -Ec '^- \[[xX]\] Not tested$' <<< "$PR_BODY" || true) + + if (( tested_count + not_tested_count != 1 )); then + echo "::error::Select exactly one testing option: Tested or Not tested." + errors=1 + fi + + # Validate the ClickUp ticket link. + ticket_link=$(sed -n 's/^\*\*ClickUp ticket link:\*\*[[:space:]]*//p' <<< "$PR_BODY" | head -n 1) + + if [[ ! "$ticket_link" =~ ^https://app\.clickup\.com/ ]]; then + echo "::error::Provide a valid ClickUp ticket URL starting with https://app.clickup.com/." + errors=1 + fi + + # Validate AI usage value. + if ! grep -Eq '^\*\*Used:\*\* (Yes|No)$' <<< "$PR_BODY"; then + echo "::error::Use of AI Tool must be Yes or No." + errors=1 + fi + + # Ensure the AI usage details are provided. + used_for=$(sed -n 's/^\*\*Used for:\*\*[[:space:]]*//p' <<< "$PR_BODY" | head -n 1) + + if [[ -z "${used_for//[[:space:]]/}" ]]; then + echo "::error::Describe how AI was used, or provide N/A." + errors=1 + fi + + if (( errors != 0 )); then + exit 1 + fi + + echo "PR description validation passed." +``` diff --git a/GDPR/Controllers/LegalConsent.php b/GDPR/Controllers/LegalConsent.php index 998525bec0..0b66043311 100644 --- a/GDPR/Controllers/LegalConsent.php +++ b/GDPR/Controllers/LegalConsent.php @@ -12,7 +12,7 @@ use Tutor\GDPR\Models\{LegalConsents, LegalConsentLogs}; use Tutor\Helpers\ValidationHelper; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Traits\JsonResponse; use WP_Error; @@ -439,7 +439,7 @@ private function update_legal_consent( int $id, array $request ) { $data = $this->prepare_legal_consent_data( $request, false ); if ( is_wp_error( $data ) ) { - $this->response_bad_request( __( 'Validation error', 'tutor' ), $data->errors, 400 ); + $this->json_response( __( 'Validation error', 'tutor' ), $data->errors, 400 ); } if ( isset( $data['consent_map'] ) && ! tutor_is_json( $data['consent_map'] ) ) { diff --git a/GDPR/Controllers/UserConsent.php b/GDPR/Controllers/UserConsent.php index 8a9505080e..2e245d10a7 100644 --- a/GDPR/Controllers/UserConsent.php +++ b/GDPR/Controllers/UserConsent.php @@ -12,7 +12,6 @@ use Exception; use Tutor\GDPR\Models\UserConsents; -use Tutor\Helpers\ValidationHelper; use TUTOR\Input; use Tutor\Traits\JsonResponse; use WP_User; @@ -33,7 +32,7 @@ class UserConsent extends BaseController { * * @since 4.0.0 * - * @var UserContents + * @var UserConsents */ private $model; @@ -67,6 +66,7 @@ private function register_hooks() { add_action( 'wp_ajax_tutor_user_consents', array( $this, 'handle_ajax_request' ) ); add_action( 'tutor_render_consent_logs_button', array( $this, 'render_consent_logs_button' ) ); add_action( 'tutor_render_consent_logs_modal', array( $this, 'render_consent_logs_modal' ) ); + add_action( 'admin_footer-users.php', array( $this, 'render_consent_logs_modal' ) ); add_filter( 'manage_users_columns', array( $this, 'add_consent_logs_column' ) ); add_filter( 'manage_users_custom_column', array( $this, 'render_consent_logs_column' ), 10, 3 ); } @@ -83,10 +83,6 @@ private function register_hooks() { public function add_consent_logs_column( $columns ) { $columns['consent_logs'] = __( 'Consent Logs', 'tutor' ); - ob_start(); - $this->render_consent_logs_modal(); - $columns['consent_logs_modal'] = ob_get_clean(); - return $columns; } @@ -110,7 +106,9 @@ public function render_consent_logs_column( $value, $column_name, $user_id ) { return $value; } - $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->get_consent_logs_html( $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; @@ -318,6 +324,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 +358,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 +371,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 ) ); ?>
-
+
{
- +
- +
{__('Character Limits', 'tutor')}
@@ -758,7 +758,7 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
- +
@@ -1080,33 +1080,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; @@ -1124,6 +1097,8 @@ const styles = { ${withPrefix && css` + width: auto; + flex-shrink: 0; justify-content: flex-end; [data-prefix] { diff --git a/assets/src/js/v3/shared/icons/types.ts b/assets/src/js/v3/shared/icons/types.ts index 9f8c9de6c3..83457cdaf0 100644 --- a/assets/src/js/v3/shared/icons/types.ts +++ b/assets/src/js/v3/shared/icons/types.ts @@ -38,8 +38,10 @@ export const icons = [ 'badge', 'badgeCheck', 'badgeInfo', + 'bag', 'barLegend', 'bars', + 'basket', 'billing', 'book', 'book2', @@ -209,6 +211,7 @@ export const icons = [ 'homeFill', 'html', 'image', + 'imageAdd', 'imagePlus', 'imagePreview', 'imagePreviewLine', diff --git a/assets/src/scss/admin-dashboard/_tutor-admin.scss b/assets/src/scss/admin-dashboard/_tutor-admin.scss index c488c725bf..8299286347 100644 --- a/assets/src/scss/admin-dashboard/_tutor-admin.scss +++ b/assets/src/scss/admin-dashboard/_tutor-admin.scss @@ -308,7 +308,7 @@ body.tutor-backend-tutor_settings #wpbody-content { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(240px, 320px); gap: 16px; - min-height: 40px; + min-height: 48px; align-items: center; width: 100%; height: 100%; @@ -326,16 +326,16 @@ body.tutor-backend-tutor_settings #wpbody-content { .tutor-image-upload-control { position: relative; - max-width: 141px; + max-width: 170px; width: 100%; + height: 48px; } .tutor-image-upload-preview { display: flex; align-items: center; justify-content: center; - max-height: 40px; - height: 100%; + height: 48px; padding: 12px; background: #f7f8fa; border-radius: 6px; @@ -343,7 +343,7 @@ body.tutor-backend-tutor_settings #wpbody-content { img { max-width: 100%; - max-height: 40px; + max-height: 100%; height: 100%; object-fit: contain; } @@ -359,7 +359,7 @@ body.tutor-backend-tutor_settings #wpbody-content { inset: 0; align-items: center; justify-content: center; - gap: 10px; + gap: 8px; background: rgba(34, 37, 43, 0.72); border-radius: 6px; @@ -367,11 +367,11 @@ body.tutor-backend-tutor_settings #wpbody-content { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; - padding: 6px; + width: 32px; + height: 32px; + padding: 8px; border: 0; - border-radius: 4px; + border-radius: 6px; background: #f3f5fa; color: var(--tutor-color-primary); font-size: 20px; @@ -398,14 +398,13 @@ body.tutor-backend-tutor_settings #wpbody-content { justify-content: center; gap: 10px; width: 100%; - height: 100%; - max-height: 40px; + height: 48px; padding: 12px; border: 1px dashed #d3d4d9; border-radius: 6px; background: #f7f8fa; color: #69738a; - font-size: 13px; + font-size: 15px; svg { color: var(--tutor-color-primary); 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/assets/src/scss/front/_order.scss b/assets/src/scss/front/_order.scss index 5cb8b3d823..b3c6e454e8 100644 --- a/assets/src/scss/front/_order.scss +++ b/assets/src/scss/front/_order.scss @@ -5,6 +5,7 @@ padding: 48px 32px 32px; border-radius: 24px; box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.06), 0px 0px 6px 0px rgba(0, 0, 0, 0.08); + word-break: break-all; display: flex; flex-direction: column; diff --git a/assets/src/scss/front/course-spotlight/quiz.scss b/assets/src/scss/front/course-spotlight/quiz.scss index 182db65c93..30d186a3c4 100644 --- a/assets/src/scss/front/course-spotlight/quiz.scss +++ b/assets/src/scss/front/course-spotlight/quiz.scss @@ -426,3 +426,24 @@ 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; + } +} 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/assets/src/scss/frontend/dashboard/_billing.scss b/assets/src/scss/frontend/dashboard/_billing.scss index a7046cc2d7..f919eb452c 100644 --- a/assets/src/scss/frontend/dashboard/_billing.scss +++ b/assets/src/scss/frontend/dashboard/_billing.scss @@ -102,13 +102,17 @@ background-color: $tutor-surface-l2-hover; height: 12px; } + + .tutor-billing-card-payment-method { + @include tutor-flex(); + } } &-right { @include tutor-flex(column, flex-end, center); flex-shrink: 0; gap: $tutor-spacing-2; - text-align: right; + text-align: end; } &-title { diff --git a/assets/src/scss/frontend/dashboard/layout/_header.scss b/assets/src/scss/frontend/dashboard/layout/_header.scss index bfebf2e43b..cf4e6b394d 100644 --- a/assets/src/scss/frontend/dashboard/layout/_header.scss +++ b/assets/src/scss/frontend/dashboard/layout/_header.scss @@ -13,6 +13,11 @@ border-bottom: 1px solid $tutor-border-idle; padding: $tutor-spacing-6 $tutor-spacing-8; + // Elevate stacking context above fixed mobile bottom nav when user drawer is open + &:has(.tutor-dashboard-header-user-avatar.active) { + z-index: calc($tutor-z-header + 1); + } + body:has(#wpadminbar) & { top: 32px; } diff --git a/assets/src/scss/frontend/learning-area/_quiz.scss b/assets/src/scss/frontend/learning-area/_quiz.scss index eae1d08bc2..27ff2dab78 100644 --- a/assets/src/scss/frontend/learning-area/_quiz.scss +++ b/assets/src/scss/frontend/learning-area/_quiz.scss @@ -877,6 +877,32 @@ $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) { + [data-tutor-motion='auto'] & { + animation: none; + background-image: none; + } + } + } + &-question { &-option { cursor: pointer; @@ -1493,3 +1519,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/classes/Admin.php b/classes/Admin.php index f4f8254ab5..584f4b8080 100644 --- a/classes/Admin.php +++ b/classes/Admin.php @@ -14,7 +14,7 @@ use Tutor\Ecommerce\OrderController; use Tutor\Helpers\HttpHelper; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\CourseModel; use Tutor\Traits\JsonResponse; 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/Earnings.php b/classes/Earnings.php index df4f212e4c..916f020e04 100644 --- a/classes/Earnings.php +++ b/classes/Earnings.php @@ -11,7 +11,7 @@ namespace TUTOR; use Tutor\Ecommerce\Ecommerce; -use TUTOR\Singleton; +use Tutor\Core\Singleton; use Tutor\Models\OrderModel; use Tutor\Helpers\QueryHelper; diff --git a/classes/Icon.php b/classes/Icon.php index 39cfa56341..118a488583 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -54,8 +54,10 @@ final class Icon { const BADGE = 'badge'; const BADGE_CHECK = 'badge-check'; const BADGE_INFO = 'badge-info'; + const BAG = 'bag'; const BARS = 'bars'; const BAR_LEGEND = 'bar-legend'; + const BASKET = 'basket'; const BILLING = 'billing'; const BOOK = 'book'; const BOOKMARK = 'bookmark'; @@ -225,6 +227,7 @@ final class Icon { const HOME_FILL = 'home-fill'; const HTML = 'html'; const IMAGE = 'image'; + const IMAGE_ADD = 'image-add'; const IMAGE_PLUS = 'image-plus'; const IMAGE_PREVIEW = 'image-preview'; const IMAGE_PREVIEW_LINE = 'image-preview-line'; diff --git a/classes/Instructor.php b/classes/Instructor.php index ee6d78c130..cb980e0a35 100644 --- a/classes/Instructor.php +++ b/classes/Instructor.php @@ -14,6 +14,7 @@ use DateInterval; use DateTime; +use Tutor\Core\Input; use Tutor\GDPR\Controllers\LegalConsent; use Tutor\Helpers\DateTimeHelper; use Tutor\Helpers\QueryHelper; @@ -849,15 +850,12 @@ public static function get_top_performing_courses_by_instructor( $instructor_id, WHERE {$earning_where_clause} GROUP BY earnings.course_id"; - $enrollment_sql = QueryHelper::prepare_raw_query( - "SELECT - post_parent AS course_id, - COUNT(DISTINCT post_author) AS total_student - FROM {$wpdb->posts} - WHERE {$enrollment_where_clause} - GROUP BY post_parent", - array() - ); + $enrollment_sql = "SELECT + post_parent AS course_id, + COUNT(DISTINCT post_author) AS total_student + FROM {$wpdb->posts} + WHERE {$enrollment_where_clause} + GROUP BY post_parent"; //phpcs:disabled $result = $wpdb->get_results( diff --git a/classes/Options_V2.php b/classes/Options_V2.php index 4796b99aa5..29d235103a 100644 --- a/classes/Options_V2.php +++ b/classes/Options_V2.php @@ -13,7 +13,7 @@ defined( 'ABSPATH' ) || exit; use TUTOR\Icon; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Traits\JsonResponse; use Tutor\Ecommerce\OptionKeys; use TUTOR\User; diff --git a/classes/Quiz.php b/classes/Quiz.php index 0158a0d8d1..f7428995ef 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 * @@ -914,16 +921,25 @@ public static function manage_attempt_answers( $attempt_answers, $attempt, $atte ); } - $total_marks = 0; - $review_required = false; + $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 ) ) { + if ( tutor_utils()->count( $question_ids ) ) { - foreach ( $quiz_answers as $question_id => $answers ) { + foreach ( $question_ids as $question_id ) { $question = QuizModel::get_quiz_question_by_id( $question_id ); if ( ! is_object( $question ) || (int) $question->quiz_id !== (int) $attempt->quiz_id ) { continue; } + + if ( isset( $committed_attempt_answers[ (int) $question_id ] ) ) { + continue; + } + + $answers = $quiz_answers[ $question_id ] ?? ''; + $question_type = $question->question_type; $is_answer_was_correct = false; @@ -1009,8 +1025,7 @@ function ( $ans ) { $is_answer_was_correct = true; } } elseif ( QuizModel::QUESTION_TYPE_OPEN_ENDED === $question_type || QuizModel::QUESTION_TYPE_SHORT_ANSWER === $question_type ) { - $review_required = true; - $given_answer = wp_kses_post( $answers ); + $given_answer = wp_kses_post( $answers ); } elseif ( QuizModel::QUESTION_TYPE_ORDERING === $question_type || QuizModel::QUESTION_TYPE_MATCHING === $question_type || QuizModel::QUESTION_TYPE_IMAGE_MATCHING === $question_type ) { $answers = (array) tutor_utils()->avalue_dot( 'answers', $answers ); @@ -1089,12 +1104,15 @@ function ( $ans ) { ); /** - * Check if question_type open ended or short ans the set - * is_correct default value null before saving + * Check if question_type open ended or short ans then set + * is_correct default value null before saving. + * Only mark review as required if an answer was submitted. */ if ( in_array( $question_type, QuizModel::get_manual_review_types(), true ) ) { $answers_data['is_correct'] = null; - $review_required = true; + if ( '' !== trim( wp_strip_all_tags( (string) $given_answer ) ) ) { + $review_required = true; + } } $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id ); @@ -1107,17 +1125,22 @@ function ( $ans ) { } } + $total_answered_questions = tutor_utils()->count( $quiz_answers ); + $earned_marks = max( 0.0, $total_marks ); + + if ( $has_committed_answers ) { + $stats = QuizModel::get_attempt_answers_stats( (int) $attempt_id ); + $earned_marks = max( 0.0, $stats->total_earned_marks ); + $total_answered_questions = $stats->total_answered_count; + } + $attempt_info = array( - 'total_answered_questions' => tutor_utils()->count( $quiz_answers ), - 'earned_marks' => max( 0.0, $total_marks ), - 'attempt_status' => QuizModel::ATTEMPT_ENDED, + 'total_answered_questions' => $total_answered_questions, + 'earned_marks' => $earned_marks, + 'attempt_status' => $review_required ? QuizModel::REVIEW_REQUIRED : QuizModel::ATTEMPT_ENDED, 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore ); - if ( $review_required ) { - $attempt_info['attempt_status'] = QuizModel::REVIEW_REQUIRED; - } - $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) ); QuizModel::update_attempt_result( $attempt_id ); @@ -1263,6 +1286,150 @@ 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(); + + $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 ); + + $attempt = self::validate_attempt( $attempt_id, $user_id ); + if ( ! $attempt ) { + $this->json_response( __( 'Operation not allowed, attempt not found or permission denied', 'tutor' ), null, HttpHelper::STATUS_FORBIDDEN ); + } + + if ( QuizModel::ATTEMPT_STARTED !== $attempt->attempt_status ) { + $this->json_response( __( 'Quiz attempt is not in progress', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); + } + + $quiz_id = (int) $attempt->quiz_id; + $quiz_settings = tutor_utils()->get_quiz_option( $quiz_id ); + $enable_answer_reveal = '1' === (string) ( $quiz_settings['enable_answer_reveal'] ?? '0' ); + if ( ! $enable_answer_reveal ) { + $this->json_response( __( 'Answer reveal is disabled for this quiz', 'tutor' ), null, HttpHelper::STATUS_FORBIDDEN ); + } + + 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 ) { + $this->json_response( __( 'Question not found or does not belong to this quiz', 'tutor' ), null, HttpHelper::STATUS_NOT_FOUND ); + } + + $supported_types = array( + QuizModel::QUESTION_TYPE_TRUE_FALSE, + QuizModel::QUESTION_TYPE_SINGLE_CHOICE, + QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE, + ); + + if ( ! in_array( $question->question_type, $supported_types, true ) ) { + $this->json_response( __( 'This question type does not support instant answer verification', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); + } + + $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' ) ); + + if ( empty( $submitted_ids ) ) { + $this->json_response( __( 'An answer is required to verify this question', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); + } + + $existing_answer_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT attempt_answer_id + FROM {$wpdb->prefix}tutor_quiz_attempt_answers + WHERE quiz_attempt_id = %d AND question_id = %d", + $attempt_id, + $question_id + ) + ); + + if ( $existing_answer_id ) { + $this->json_response( __( 'Answer has already been committed for this question', 'tutor' ), null, HttpHelper::STATUS_BAD_REQUEST ); + } + + $is_correct = false; + if ( ! 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 ); + } + + $question_mark = (float) ( $question->question_mark ?? 0 ); + $achieved_mark = $is_correct ? $question_mark : 0.0; + + $given_answer = ( QuizModel::QUESTION_TYPE_MULTIPLE_CHOICE === $question->question_type ) + ? maybe_serialize( $submitted_ids ) + : (string) ( $submitted_ids[0] ?? '' ); + + $answers_data = array( + 'user_id' => $user_id, + 'quiz_id' => $quiz_id, + 'question_id' => $question_id, + 'quiz_attempt_id' => $attempt_id, + 'given_answer' => $given_answer, + 'question_mark' => $question_mark, + 'achieved_mark' => $achieved_mark, + 'minus_mark' => 0, + 'is_correct' => $is_correct ? QuizModel::ATTEMPT_ANSWER_CORRECT : QuizModel::ATTEMPT_ANSWER_INCORRECT, + ); + + $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question->question_type, $user_id, $attempt_id ); + + $inserted = $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data ); + + if ( false === $inserted ) { + $this->json_response( __( 'Failed to save answer', 'tutor' ), null, HttpHelper::STATUS_INTERNAL_SERVER_ERROR ); + } + + $explanation = apply_filters( 'tutor_quiz_question_answer_explanation', '', $question, $attempt ); + + $this->json_response( + '', + array( + 'question_id' => $question_id, + 'is_correct' => $is_correct, + 'correct_answer_ids' => $correct_answer_ids, + 'answer_explanation' => $explanation, + ), + HttpHelper::STATUS_OK + ); + } + /** * Review quiz answer * @@ -1876,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; } /** @@ -2646,6 +2805,7 @@ public static function render_question( $question, $index = 0 ) { 'question' => $question, 'question_settings' => $question_settings, 'question_type' => $template, + 'committed_answer' => $question->committed_answer ?? null, ) ); } 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. diff --git a/classes/RestAPI.php b/classes/RestAPI.php index 738bd02561..7347add2fc 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -10,6 +10,8 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; + if ( ! defined( 'ABSPATH' ) ) { exit; } @@ -31,7 +33,21 @@ class RestAPI { * * @var string */ - private $namespace = 'tutor/v1'; + private static $namespace = 'tutor/v1'; + + /** + * Get the REST API namespace. + * + * Static so callers do not need the RestAPI instance (e.g. early + * rest_pre_dispatch hooks where tutor()->rest_api is unavailable). + * + * @since 4.2.0 + * + * @return string + */ + public static function get_namespace() { + return self::$namespace; + } /** * Course post type @@ -135,7 +151,12 @@ public function __construct() { public function check_permission( $response, $handler, $request ) { $id = absint( $request['id'] ); $method = $request->get_method(); - $write_methods = array( 'POST', 'PUT', 'PATCH', 'DELETE' ); + $write_methods = array( + HttpHelper::METHOD_POST, + HttpHelper::METHOD_PUT, + HttpHelper::METHOD_PATCH, + HttpHelper::METHOD_DELETE, + ); $is_write = in_array( $method, $write_methods, true ); if ( ! $id ) { @@ -157,8 +178,15 @@ public function check_permission( $response, $handler, $request ) { return $response; } - // Prevent write actions which are only allowed for author. - if ( $is_write && ! current_user_can( 'edit_post', $id ) ) { + // Prefer Tutor manage rules over raw edit_post. For another author's course, + // edit_post maps to edit_others_tutor_courses, which administrators may lack + // even though User::is_admin() / can_user_edit_course() should allow the write. + $can_edit = User::is_admin() + || tutor_utils()->can_user_edit_course( get_current_user_id(), $id ) + || current_user_can( 'edit_post', $id ); + + // Prevent write actions which are only allowed for author / course managers. + if ( $is_write && ! $can_edit ) { return new \WP_Error( 'rest_forbidden', tutor_utils()->error_message(), @@ -166,8 +194,9 @@ public function check_permission( $response, $handler, $request ) { ); } - // Prevent access to non-publish posts, only author can access. - if ( 'publish' !== $post->post_status && ! current_user_can( 'edit_post', $id ) ) { + // 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(), @@ -224,6 +253,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 * @@ -232,248 +277,235 @@ private function loader( $class_name ) { * @return void */ public function init_routes() { + // Auth: login / refresh / logout. + register_rest_route( + self::$namespace, + '/auth/login', + array( + 'methods' => HttpHelper::METHOD_POST, + 'callback' => array( RestAuth::class, 'login' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + + register_rest_route( + self::$namespace, + '/auth/refresh', + array( + 'methods' => HttpHelper::METHOD_POST, + 'callback' => array( RestAuth::class, 'refresh' ), + 'permission_callback' => '__return_true', + ) + ); + + register_rest_route( + self::$namespace, + '/auth/logout', + array( + 'methods' => HttpHelper::METHOD_POST, + 'callback' => array( RestAuth::class, 'logout' ), + 'permission_callback' => '__return_true', + ) + ); + // Courses. register_rest_route( - $this->namespace, + self::$namespace, '/courses', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->course_obj, 'course', ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_optional_access_token' ), ) ); // Course details. register_rest_route( - $this->namespace, + self::$namespace, '/courses/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->course_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_detail' ), ) ); // Course topic. register_rest_route( - $this->namespace, + self::$namespace, '/topics', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->topic_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_topics' ), ) ); // Lesson by topic. register_rest_route( - $this->namespace, + self::$namespace, '/lessons', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->lesson_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) ); // Course announcement by course id. register_rest_route( - $this->namespace, + self::$namespace, '/course-announcement/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->announcement_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_authenticated_course_content' ), ) ); // Quiz by topic id. register_rest_route( - $this->namespace, + self::$namespace, '/quizzes', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->quiz_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_authenticated_by_topic' ), ) ); // Quiz by quiz id. register_rest_route( - $this->namespace, + self::$namespace, '/quizzes/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->quiz_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); // Quiz question answer by quiz id. register_rest_route( - $this->namespace, + self::$namespace, '/quiz-question-answer/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->quiz_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); // Quiz attempt details by quiz id. register_rest_route( - $this->namespace, + self::$namespace, '/quiz-attempt-details/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->quiz_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); // Author detail by id. register_rest_route( - $this->namespace, + self::$namespace, '/author-information/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->author_obj, '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' ), + 'permission_callback' => array( RestAuth::class, 'permission_public_author' ), ) ); // Reviews by course id. register_rest_route( - $this->namespace, + self::$namespace, '/course-rating/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->rating_obj, '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' ), + 'permission_callback' => array( RestAuth::class, 'permission_optional_access_token' ), ) ); // Get course content by id. register_rest_route( - $this->namespace, + self::$namespace, '/course-contents/(?P\d+)', array( - 'methods' => 'GET', + 'methods' => HttpHelper::METHOD_GET, 'callback' => array( $this->course_obj, '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, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) ); } diff --git a/classes/Tools.php b/classes/Tools.php index b37deb644b..61107e1531 100644 --- a/classes/Tools.php +++ b/classes/Tools.php @@ -14,7 +14,7 @@ exit; } -use TUTOR\Input; +use Tutor\Core\Input; /** * Tools class 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/classes/Tutor.php b/classes/Tutor.php index e92ba61e39..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; @@ -990,16 +991,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, - PRIMARY KEY (id), - KEY coupon_code (coupon_code), - KEY user_id (user_id), - 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, @@ -1059,6 +1051,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 37781f0769..3f44436271 100644 --- a/classes/Upgrader.php +++ b/classes/Upgrader.php @@ -90,6 +90,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 +381,22 @@ 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() { + 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 ) ); + } + } + /** * Email table deployment * diff --git a/classes/Utils.php b/classes/Utils.php index 68e612141b..3cea1ea6a8 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -5416,7 +5416,7 @@ public function get_random_questions_by_quiz( $quiz_id = 0 ) { $total_questions = (int) $attempt->total_questions; $questions_order = $this->get_quiz_option( $quiz_id, 'questions_order', 'rand' ); - $order_by = 'RAND()'; + $order_by = ! empty( $attempt->attempt_id ) ? 'RAND(' . (int) $attempt->attempt_id . ')' : 'RAND()'; $order = ''; if ( 'asc' === $questions_order ) { @@ -9709,6 +9709,8 @@ private function assign_child_count( array $course_meta, $post_type ) { /** * Get course meta data. * + * @since 4.2.0 Removed the total_enrollment count since no caller is using it + * * @param int|int[] $course_id course id. * * @return mixed @@ -9738,7 +9740,6 @@ function ( $id ) { FROM {$wpdb->posts} course LEFT JOIN {$wpdb->posts} topic ON course.ID=topic.post_parent LEFT JOIN {$wpdb->posts} content ON topic.ID=content.post_parent - LEFT JOIN {$wpdb->posts} enrollment ON course.ID=enrollment.post_parent WHERE topic.post_parent IN ($course_ids)" ); @@ -9752,7 +9753,6 @@ function ( $id ) { 'tutor_quiz' => array(), 'lesson' => array(), 'topics' => 0, - 'tutor_enrolled' => 0, ); } @@ -9779,7 +9779,6 @@ function ( $id ) { } } - $course_meta = $this->assign_child_count( $course_meta, 'tutor_enrolled' ); $course_meta = $this->assign_child_count( $course_meta, 'topics' ); // Return single count if the course id was single. diff --git a/classes/WooCommerce.php b/classes/WooCommerce.php index d0986be4d3..c4259599b8 100644 --- a/classes/WooCommerce.php +++ b/classes/WooCommerce.php @@ -1071,7 +1071,7 @@ public function filter_order_history_status_options( $options, $seleted ) { $url = get_pagenum_link(); $user_id = get_current_user_id(); - $statuses = array_merge( array( 'all' => 'All' ), wc_get_order_statuses() ); + $statuses = array_merge( array( 'all' => __( 'All', 'tutor' ) ), wc_get_order_statuses() ); if ( isset( $statuses['wc-checkout-draft'] ) ) { unset( $statuses['wc-checkout-draft'] ); } diff --git a/components/BaseComponent.php b/components/BaseComponent.php index 0be782e878..2a907aee4c 100644 --- a/components/BaseComponent.php +++ b/components/BaseComponent.php @@ -13,7 +13,7 @@ namespace Tutor\Components; -use TUTOR\Input; +use Tutor\Core\Input; defined( 'ABSPATH' ) || exit; diff --git a/components/DateFilter.php b/components/DateFilter.php index e2b5c230ad..1423314298 100644 --- a/components/DateFilter.php +++ b/components/DateFilter.php @@ -11,7 +11,7 @@ namespace Tutor\Components; use TUTOR\Icon; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Components\Constants\Size; use Tutor\Components\Popover; diff --git a/components/DropdownFilter.php b/components/DropdownFilter.php index 2462aab229..df2ebe0e3e 100644 --- a/components/DropdownFilter.php +++ b/components/DropdownFilter.php @@ -16,7 +16,7 @@ use Tutor\Components\Constants\Color; use Tutor\Components\Popover; use TUTOR\Icon; -use TUTOR\Input; +use Tutor\Core\Input; defined( 'ABSPATH' ) || exit; 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; diff --git a/components/SearchFilter.php b/components/SearchFilter.php index dd68dc53e7..678f9eea39 100644 --- a/components/SearchFilter.php +++ b/components/SearchFilter.php @@ -16,7 +16,7 @@ use Tutor\Components\Constants\Size; use Tutor\Components\Constants\Color; use TUTOR\Icon; -use TUTOR\Input; +use Tutor\Core\Input; defined( 'ABSPATH' ) || exit; diff --git a/components/Tabs.php b/components/Tabs.php index 6ab6cc1b86..a935bb4775 100644 --- a/components/Tabs.php +++ b/components/Tabs.php @@ -13,7 +13,7 @@ namespace Tutor\Components; -use TUTOR\Input; +use Tutor\Core\Input; defined( 'ABSPATH' ) || exit; diff --git a/composer.json b/composer.json index 1e895639ec..0760a57abc 100644 --- a/composer.json +++ b/composer.json @@ -5,11 +5,13 @@ "autoload": { "psr-4": { "TUTOR\\": "classes/", + "Tutor\\Core\\": "core/", "Tutor\\Components\\": "components/", "Tutor\\Cache\\": "cache/", "Tutor\\Migrations\\":"migrations/", "Tutor\\Models\\": "models/", "Tutor\\Helpers\\": "helpers/", + "Tutor\\Exceptions\\": "exceptions/", "Tutor\\Traits\\": "traits/", "Tutor\\Ecommerce\\": "ecommerce/", "Tutor\\PaymentGateways\\": "ecommerce/PaymentGateways", diff --git a/classes/Container.php b/core/Container.php similarity index 99% rename from classes/Container.php rename to core/Container.php index d35f6cc8ee..f3aadfaf0f 100644 --- a/classes/Container.php +++ b/core/Container.php @@ -3,13 +3,13 @@ * Container for managing dependencies and bindings. * Supports binding, singleton registration, and autowiring via reflection. * - * @package TutorPro\Classes + * @package Tutor\Core * @author Themeum * @link https://themeum.com * @since 3.7.0 */ -namespace TUTOR; +namespace Tutor\Core; use ReflectionClass; diff --git a/core/DB.php b/core/DB.php new file mode 100644 index 0000000000..dca3a5f5f5 --- /dev/null +++ b/core/DB.php @@ -0,0 +1,2053 @@ +prepare(). + * + * @package Tutor\Core + * @since 4.2.0 + */ + +namespace Tutor\Core; + +use Closure; +use Tutor\Exceptions\DatabaseException; + +/** + * 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: + * DB::table('tutor_orders') + * ->where('status', 'completed') + * ->order_by('id', 'DESC') + * ->limit(10) + * ->get(); + * + * @since 4.2.0 + */ +class DB { + + /** + * 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 DB query 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 ) { + $aliased = self::parse_column_alias( $column ); + if ( null !== $aliased ) { + $this->columns[] = $aliased; + } elseif ( 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. + * + * Supports standard 4-argument syntax or a closure for multiple ON conditions. + * + * @since 4.2.0 + * + * @param string $table Table to join (may include alias, e.g., 'users AS u'). + * @param string|\Closure $first First column in the ON condition, or a closure for multiple ON conditions. + * @param string $operator Comparison operator (omitted if using closure). + * @param string $second Second column in the ON condition (omitted if using closure). + * + * @return self + */ + public function join( string $table, $first, string $operator = '', string $second = '' ): self { + if ( $first instanceof Closure ) { + return $this->add_join_closure( 'INNER', $table, $first ); + } + + return $this->add_join( 'INNER', $table, $first, $operator, $second ); + } + + /** + * Add a LEFT JOIN clause. + * + * Supports standard 4-argument syntax or a closure for multiple ON conditions. + * + * @since 4.2.0 + * + * @param string $table Table to join. + * @param string|Closure $first First column or Closure. + * @param string $operator Comparison operator. + * @param string $second Second column. + * + * @return self + */ + public function left_join( string $table, $first, string $operator = '', string $second = '' ): self { + if ( $first instanceof Closure ) { + return $this->add_join_closure( 'LEFT', $table, $first ); + } + + return $this->add_join( 'LEFT', $table, $first, $operator, $second ); + } + + /** + * Add a RIGHT JOIN clause. + * + * Supports standard 4-argument syntax or a closure for multiple ON conditions. + * + * @since 4.2.0 + * + * @param string $table Table to join. + * @param string|Closure $first First column or Closure. + * @param string $operator Comparison operator. + * @param string $second Second column. + * + * @return self + */ + public function right_join( string $table, $first, string $operator = '', string $second = '' ): self { + if ( $first instanceof Closure ) { + return $this->add_join_closure( 'RIGHT', $table, $first ); + } + + return $this->add_join( 'RIGHT', $table, $first, $operator, $second ); + } + + /** + * Internal method to parse and prepare a join table with optional alias. + * + * @since 4.2.0 + * + * @param string $table Table name, may include alias e.g., 'users AS u'. + * + * @return string + */ + private function parse_join_table( string $table ): string { + $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}"; + } + + return $table_expr; + } + + /** + * Internal method to add a JOIN clause using a closure collector. + * + * @since 4.2.0 + * + * @param string $type Join type (INNER, LEFT, RIGHT, CROSS). + * @param string $table Table name, may include alias. + * @param callable $callback Closure to configure join conditions. + * + * @return self + */ + private function add_join_closure( string $type, string $table, callable $callback ): self { + $type = strtoupper( $type ); + + $allowed_types = array( 'INNER', 'LEFT', 'RIGHT', 'CROSS' ); + if ( ! in_array( $type, $allowed_types, true ) ) { + return $this; + } + + $table_expr = $this->parse_join_table( $table ); + $collector = self::create_join_collector(); + + $callback( $collector ); + + if ( ! empty( $collector->conditions ) ) { + $on_parts = ''; + foreach ( $collector->conditions as $index => $condition ) { + if ( 0 === $index ) { + $on_parts .= $condition['sql']; + } else { + $on_parts .= " {$condition['boolean']} {$condition['sql']}"; + } + } + $this->joins[] = "{$type} JOIN {$table_expr} ON {$on_parts}"; + } + + return $this; + } + + /** + * Create an anonymous class collector for closure-based joins. + * + * @since 4.2.0 + * + * @return object + */ + private static function create_join_collector(): object { + return new class() { + + /** + * Collected ON conditions. + * + * @var array + */ + public $conditions = array(); + + /** + * Add an ON condition (AND). + * + * @param string $first First column. + * @param string $operator_or_second Operator or second column if 2 args. + * @param string|null $second Second column if 3 args. + * + * @return self + */ + public function on( string $first, string $operator_or_second, ?string $second = null ): self { + if ( null === $second ) { + return $this->add_on( $first, '=', $operator_or_second, 'AND' ); + } + + return $this->add_on( $first, $operator_or_second, $second, 'AND' ); + } + + /** + * Add an OR ON condition. + * + * @param string $first First column. + * @param string $operator_or_second Operator or second column if 2 args. + * @param string|null $second Second column if 3 args. + * + * @return self + */ + public function or_on( string $first, string $operator_or_second, ?string $second = null ): self { + if ( null === $second ) { + return $this->add_on( $first, '=', $operator_or_second, 'OR' ); + } + + return $this->add_on( $first, $operator_or_second, $second, 'OR' ); + } + + /** + * Internal method to validate and store an ON condition. + * + * @param string $first First column. + * @param string $operator Comparison operator. + * @param string $second Second column. + * @param string $boolean Boolean connector (AND/OR). + * + * @return self + */ + private function add_on( string $first, string $operator, string $second, string $boolean ): self { + if ( DB::is_valid_column( $first ) + && DB::is_valid_operator( $operator ) + && DB::is_valid_column( $second ) + ) { + $operator = strtoupper( $operator ); + $this->conditions[] = array( + 'sql' => "{$first} {$operator} {$second}", + 'boolean' => $boolean, + ); + } + + return $this; + } + }; + } + + /** + * 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; + } + + $table_expr = $this->parse_join_table( $table ); + + 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(); + + if ( $saved_distinct ) { + if ( '*' !== $column ) { + $this->raw_columns = array( "COUNT(DISTINCT {$column}) AS aggregate" ); + } elseif ( 1 === count( $saved_columns ) && self::is_valid_column( $saved_columns[0] ) && '*' !== $saved_columns[0] ) { + $this->raw_columns = array( "COUNT(DISTINCT {$saved_columns[0]}) AS aggregate" ); + } else { + $this->raw_columns = array( "COUNT({$column}) AS aggregate" ); + } + } else { + $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 ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $total_count = (int) $wpdb->get_var( 'SELECT FOUND_ROWS()' ); + $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 DatabaseException 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 ( false === $result ) { + throw new DatabaseException( esc_html( $wpdb->last_error ) ); + } + + return $wpdb->insert_id; + } + + /** + * 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 DatabaseException If a database error occurs. + * + * @return bool True on success. + */ + public function insert_multiple( array $rows ): bool { + global $wpdb; + + if ( empty( $rows ) ) { + return false; + } + + $first_row = reset( $rows ); + $columns = is_array( $first_row ) ? array_keys( $first_row ) : array(); + $valid_cols = array(); + foreach ( $columns as $col ) { + if ( self::is_valid_identifier( $col ) ) { + $valid_cols[] = $col; + } + } + + if ( empty( $valid_cols ) ) { + return false; + } + + $column_list = implode( ', ', $valid_cols ); + + $value_groups = array(); + foreach ( $rows as $row ) { + $row_parts = array(); + foreach ( $valid_cols 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(). + $result = $wpdb->query( $sql ); + + if ( false === $result ) { + throw new DatabaseException( 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 DatabaseException 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 ) { + if ( ! self::is_valid_identifier( $column ) ) { + continue; + } + + if ( is_null( $value ) ) { + $set_parts[] = "{$column} = NULL"; + } else { + $prepared = self::prepare_value( $value ); + $set_parts[] = "{$column} = {$prepared}"; + } + } + + if ( empty( $set_parts ) ) { + return false; + } + + $set_clause = implode( ', ', $set_parts ); + $where_clause = $this->build_where_sql(); + + $table_name = ! empty( $this->alias ) ? "{$this->table} AS {$this->alias}" : $this->table; + $sql = "UPDATE {$table_name} 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. + $result = $wpdb->query( $sql ); + + if ( false === $result ) { + throw new DatabaseException( 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 DatabaseException 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 ( false === $result ) { + throw new DatabaseException( 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 + // ───────────────────────────────────────────── + + /** + * Parse a column expression that may contain an alias. + * + * Supports: 'col AS alias', 'col as alias', 'col alias'. + * Returns normalized 'col AS alias', or null if invalid. + * + * @since 4.2.0 + * + * @param string $expression Column expression. + * + * @return string|null Normalized 'column AS alias' or null. + */ + public static function parse_column_alias( string $expression ): ?string { + $parts = preg_split( '/\s+(?:AS\s+)?/i', trim( $expression ), 2 ); + + if ( 2 !== count( $parts ) ) { + return null; + } + + $column = trim( $parts[0] ); + $alias = trim( $parts[1] ); + + if ( self::is_valid_column( $column ) && self::is_valid_identifier( $alias ) ) { + return "{$column} AS {$alias}"; + } + + return null; + } + + /** + * 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. + */ + public 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. + */ + public 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. + */ + public 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/classes/Input.php b/core/Input.php similarity index 99% rename from classes/Input.php rename to core/Input.php index fe3c7bfa2b..d3a84c8d78 100644 --- a/classes/Input.php +++ b/core/Input.php @@ -2,17 +2,16 @@ /** * Input class for sanitize GET and POST request * - * @package Tutor + * @package Tutor\Core * @author Themeum * @link https://themeum.com * @since 2.0.2 */ -namespace TUTOR; +namespace Tutor\Core; + +defined( 'ABSPATH' ) || exit; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} /** * Input class * diff --git a/classes/Singleton.php b/core/Singleton.php similarity index 92% rename from classes/Singleton.php rename to core/Singleton.php index 5174d613ae..1f6f1712b2 100644 --- a/classes/Singleton.php +++ b/core/Singleton.php @@ -2,17 +2,15 @@ /** * An abstract base class to make Singleton class * - * @package Tutor\Classes + * @package Tutor\Core * @author Themeum * @link https://themeum.com * @since 3.0.0 */ -namespace TUTOR; +namespace Tutor\Core; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class Singleton diff --git a/docs/api/Authentication/Login.bru b/docs/api/Authentication/Login.bru new file mode 100644 index 0000000000..596c9235cb --- /dev/null +++ b/docs/api/Authentication/Login.bru @@ -0,0 +1,95 @@ +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:** Any valid API key permission (`Read` in Free; Pro may also offer `Write`, `Delete`, `Read/Write`, or `All`) + + ## 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. Subsequent Tutor REST calls use **Bearer JWT only** (not API key headers). + + ### 401 Unauthorized + ```json + { + "code": "tutor_auth_login", + "message": "Invalid username or password.", + "data": { + "status": 401 + } + } + ``` + + ### 403 Forbidden + ```json + { + "code": "tutor_auth_login", + "message": "API key and secret are required.", + "data": { + "status": 403 + } + } + ``` + + (Invalid key/secret uses the same envelope with message `"Invalid API key or secret."`.) +} diff --git a/docs/api/Authentication/Logout.bru b/docs/api/Authentication/Logout.bru new file mode 100644 index 0000000000..05eafc3c47 --- /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 (hash removed from that user’s stored list). | + | `all` | `boolean` | No | When `true`, bump `token_version` and delete all refresh tokens for the user. Resolves the user from Bearer access token, or from a valid `refresh_token` if Bearer is absent. | + + \* Provide `refresh_token` and/or set `all: true` with a Bearer token (or a valid `refresh_token` to identify the user). Omitting both still returns success but revokes nothing. + + ## 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..260b98a7c8 --- /dev/null +++ b/docs/api/Authentication/Refresh Token.bru @@ -0,0 +1,83 @@ +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 + ```json + { + "code": "tutor_auth_refresh", + "message": "Invalid refresh token.", + "data": { + "status": 401 + } + } + ``` + + ### 403 Forbidden + ```json + { + "code": "tutor_auth_refresh", + "message": "API key has been revoked.", + "data": { + "status": 403 + } + } + ``` +} 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..253e71f621 100644 --- a/docs/api/Courses/course-announcement-{id}.bru +++ b/docs/api/Courses/course-announcement-{id}.bru @@ -7,39 +7,39 @@ 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 { - # Get Course Announcements + # GET /course-announcement/{id} - Retrieves all broadcast announcements published by the course instructors for a specific course. + Retrieves announcements for a course. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/course-announcement/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **Requires** Bearer JWT (always authenticated). + - User must be able to view the course. + - Permission from the bound API key: `Read`, `Read/Write`, or `All`. ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the course. | + | `id` | `integer` | **Yes** | Course post ID. | ## Responses ### 200 OK (Success) ```json { - "code": "success", + "code": "tutor_read_announcement", "message": "Announcement retrieved successfully", "data": [ { @@ -55,9 +55,11 @@ docs { ### 404 Not Found ```json { - "code": "not_found", + "code": "tutor_read_announcement", "message": "Announcement not found for given ID", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Courses/course-contents-{id}.bru b/docs/api/Courses/course-contents-{id}.bru index 307edb0e26..34564e7075 100644 --- a/docs/api/Courses/course-contents-{id}.bru +++ b/docs/api/Courses/course-contents-{id}.bru @@ -7,39 +7,43 @@ 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 { - # Get Course Contents Tree + # GET /course-contents/{id} - Retrieves the complete hierarchical curriculum structure for a course, grouping topics and their nested child items (lessons, quizzes, assignments). + Retrieves the curriculum tree (topics → lessons / quizzes / assignments). ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/course-contents/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **With Bearer JWT:** user must be able to view the course (enrolled / instructor / public). + - **Without JWT:** allowed only when the course is **public**. + - Permission for JWT callers comes from the bound API key (`Read`, `Read/Write`, or `All`). ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the course. | + | `id` | `integer` | **Yes** | Course post ID. | + + ## Notes + + When learning payloads cannot be revealed, topic `summary` is empty and content items are outline-only (`ID`, `post_title`, `post_name`, `post_type`, `menu_order`, optional `duration`) — no `post_content`. ## Responses ### 200 OK (Success) ```json { - "code": "success", + "code": "tutor_read_course", "message": "Course contents retrieved successfully", "data": [ { @@ -52,14 +56,16 @@ docs { "post_title": "Lesson 1: What is a REST API?", "post_content": "Detailed overview text...", "post_name": "what-is-a-rest-api", - "post_type": "lesson" + "post_type": "lesson", + "menu_order": 0 }, { "ID": 302, "post_title": "Quiz 1: HTTP Basics", "post_content": "Test your knowledge of status codes.", "post_name": "quiz-1-http-basics", - "post_type": "tutor_quiz" + "post_type": "tutor_quiz", + "menu_order": 1 } ] } @@ -70,9 +76,11 @@ docs { ### 404 Not Found ```json { - "code": "not_found", + "code": "tutor_read_course", "message": "Contents for this course with the given course id not found", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Courses/course-rating-{id}.bru b/docs/api/Courses/course-rating-{id}.bru index 901b029a62..6e4a0f9008 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,72 +15,117 @@ params:query { ~limit: 10 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { - # Get Course Ratings & Reviews + # GET /course-rating/{id} - Retrieves overall rating statistics (count, average rating, star breakdown) and a paginated list of approved student reviews for a course. + Retrieves rating summary and reviews for a course. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/course-rating/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **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`). ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the course. | + | `id` | `integer` | **Yes** | Course post ID. | ## Query Parameters | Parameter | Type | Default | Description | | :--- | :--- | :--- | :--- | - | `offset` | `integer` | `0` | Offset for pagination of reviews. | - | `limit` | `integer` | `10` | Number of reviews to return per request. | + | `offset` | `integer` | `0` | Review list offset. | + | `limit` | `integer` | `10` | Page size (capped at 100). | ## Responses - ### 200 OK (Success) + ### 200 OK — guest (reduced reviews) ```json { - "code": "success", + "code": "tutor_read_rating", "message": "Course rating retrieved successfully", "data": { "rating_count": 8, "rating_avg": 4.88, - "five_star_count": 7, - "four_star_count": 1, - "three_star_count": 0, - "two_star_count": 0, - "one_star_count": 0, + "count_by_value": { + "5": 7, + "4": 1, + "3": 0, + "2": 0, + "1": 0 + }, "reviews": [ { - "comment_ID": "85", - "comment_post_ID": "105", - "comment_author": "Alex Smith", - "comment_content": "Exceptional course! Very clear and concise.", - "rating": 5, - "review_title": "Great practical examples" + "display_name": "Alex", + "comment_content": "Great course!", + "comment_date_gmt": "2026-01-10 12:00:00", + "rating": 5 } ] } } ``` + ### 200 OK — authenticated + ```json + { + "code": "tutor_read_rating", + "message": "Course rating retrieved successfully", + "data": { + "rating_count": 8, + "rating_avg": 4.88, + "rating_sum": 39, + "count_by_value": { + "5": 7, + "4": 1, + "3": 0, + "2": 0, + "1": 0 + }, + "reviews": [ + { + "comment_ID": "42", + "user_id": "14", + "display_name": "Alex", + "comment_content": "Great course!", + "comment_date": "2026-01-10 12:00:00", + "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 + } + } + } + ``` + ### 404 Not Found ```json { - "code": "not_found", - "message": "Rating not found for given ID", - "data": [] + "code": "tutor_read_rating", + "message": "Course not found", + "data": { + "status": 404 + } } ``` + Returned when the course is missing, not a course post type, not `publish`/`private`, or is `private` without enrolled / content access. } diff --git a/docs/api/Courses/courses-{id}.bru b/docs/api/Courses/courses-{id}.bru index aa973088d4..1dce358f80 100644 --- a/docs/api/Courses/courses-{id}.bru +++ b/docs/api/Courses/courses-{id}.bru @@ -7,70 +7,90 @@ 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 { - # Get Course Details + # GET /courses/{id} - Retrieves detailed metadata, settings, requirements, benefits, target audience, and video settings for a single course. + Retrieves course additional metadata (price type, duration, level, benefits, video, etc.). ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/courses/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **With Bearer JWT:** allowed for marketing/overview (enrollment not required). Learning content is gated on other routes/payloads. + - **Without JWT:** public courses always; non-public courses only if `student_must_login_to_view_course` is off. + - Published courses only for marketing viewers; drafts/private require a JWT course editor. + - `course_settings` only for enrolled students, course instructors/admins, or IA content access — not for guests or non-enrolled JWT (including on public courses). Trailer `video` and other marketing fields match single-course.php. ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the course. | + | `id` | `integer` | **Yes** | Course post ID. | ## Responses ### 200 OK (Success) + + Meta fields use `get_post_meta( ..., false )`, so most values are **arrays** (often a single-element array). + ```json { - "code": "course_detail", + "code": "tutor_read_course", "message": "Course detail retrieved successfully", "data": { - "course_settings": { - "maximum_students": "100", - "is_public": "no" - }, - "course_price_type": "paid", - "course_duration": { - "hours": "4", - "minutes": "30" - }, - "course_level": "all_levels", - "course_benefits": "Learn modern web development principles and build complete apps.", - "course_requirements": "Basic knowledge of HTML and CSS.", - "course_target_audience": "Beginner developers looking to expand their frontend skills.", - "course_material_includes": "Source code repository access, Certificate of Completion.", - "video": { - "source": "youtube", - "source_youtube": "https://www.youtube.com/watch?v=example" - }, + "course_settings": [ + { + "maximum_students": 100, + "content_drip_enabled": 0 + } + ], + "course_price_type": ["paid"], + "course_duration": [ + { + "hours": "4", + "minutes": "30" + } + ], + "course_level": ["all_levels"], + "course_benefits": ["Learn modern web development principles."], + "course_requirements": ["Basic knowledge of HTML and CSS."], + "course_target_audience": ["Beginner developers."], + "course_material_includes": ["Source code, Certificate."], + "video": [ + { + "source": "youtube", + "source_youtube": "https://www.youtube.com/watch?v=example", + "source_vimeo": "", + "source_external_url": "", + "source_embedded": "", + "source_shortcode": "", + "source_video_id": "", + "poster": "" + } + ], "disable_qa": false } } ``` - ### 404 / Not Found + `video` is the stored `_video` meta shape (`source` + `source_youtube`, …), not the Pro write body `{ source_type, source }`. + + ### 404 Not Found ```json { - "code": "course_detail", + "code": "tutor_read_course", "message": "Detail not found for given ID", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Courses/courses.bru b/docs/api/Courses/courses.bru index d18e314a43..6335e2fdce 100644 --- a/docs/api/Courses/courses.bru +++ b/docs/api/Courses/courses.bru @@ -7,92 +7,92 @@ meta { get { url: {{base_url}}/courses body: none - auth: basic + auth: bearer } params:query { ~paged: 1 - ~order: ASC + ~order: DESC ~orderby: title ~categories: design,development ~tags: beginner } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { - # Get All Courses + # GET /courses - Retrieves a paginated list of published courses along with categories, tags, author info, rating summary, price, and thumbnail. + Retrieves a paginated list of published courses (catalog). ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/courses` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **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 | Parameter | Type | Default | Description | | :--- | :--- | :--- | :--- | - | `paged` | `integer` | `1` | Page number for pagination. | - | `order` | `string` | `ASC` | Sort order direction: `ASC` or `DESC`. | - | `orderby` | `string` | `title` | Sort by field: `title`, `date`, `price`, `ID`, `rand`, etc. When `orderby=price` is used, WooCommerce product prices are queried. | - | `categories` | `string` | _None_ | Comma-separated category names (e.g. `web-development,design`). | - | `tags` | `string` | _None_ | Comma-separated tag names (e.g. `javascript,beginners`). | + | `paged` | `integer` | `1` | Page number. | + | `order` | `string` | `DESC` | `ASC` or `DESC`. Invalid / omitted values fall back to **`DESC`**. | + | `orderby` | `string` | `title` | Whitelisted: `title`, `date`, `modified`, `ID`, `name`, `author`, `menu_order`, `price`. | + | `categories` | `string` | _None_ | Comma-separated category **names**. | + | `tags` | `string` | _None_ | Comma-separated tag **names**. | + + ## Guest vs authenticated payloads + + ### Guest catalog card + Each post includes: `ID`, `post_title`, `post_name`, `thumbnail_url`, `price`, `sale_price`, `post_author` (`ID`, `display_name`), `ratings` (`rating_count`, `rating_avg`), `course_category` / `course_tag` as `[{ term_id, name, slug }]`. + + ### Authenticated + Full WP post fields (minus `filter` / `post_password`) plus `thumbnail_url`, `sale_price`, `price`, `additional_info`, richer `post_author` (`ID`, `display_name`, `user_nicename`, and private fields when allowed), full `ratings` from `get_course_rating`, and full term objects for category/tag. ## Responses - ### 200 OK (Success) + ### 200 OK — guest (catalog card) ```json { - "code": "success", + "code": "tutor_read_course", "message": "Course retrieved successfully", "data": { "posts": [ { "ID": 105, - "post_author": { - "ID": "1", - "user_login": "instructor_jane", - "user_nicename": "instructor_jane", - "user_email": "jane@example.com", - "display_name": "Jane Doe" - }, - "post_date": "2026-01-15 10:00:00", "post_title": "Modern JavaScript from Scratch", - "post_content": "Course description and syllabus overview...", - "post_status": "publish", "post_name": "modern-javascript-from-scratch", "thumbnail_url": "http://tutor-dev.local/wp-content/uploads/course-js.jpg", + "price": "49.00", + "sale_price": "39.00", + "post_author": { + "ID": 1, + "display_name": "Jane Doe" + }, "ratings": { "rating_count": 12, "rating_avg": 4.75 }, "course_category": [ { - "term_id": 4, - "name": "Web Development", - "slug": "web-development" + "term_id": 3, + "name": "Development", + "slug": "development" } ], "course_tag": [ { - "term_id": 9, - "name": "JavaScript", - "slug": "javascript" + "term_id": 8, + "name": "Beginner", + "slug": "beginner" } - ], - "price": "49.00", - "additional_info": { - "course_duration": "6 hours 30 mins", - "course_level": "beginner" - } + ] } ], "total_course": 25, @@ -101,22 +101,61 @@ docs { } ``` - ### 404 Not Found + ### 200 OK — authenticated (excerpt) ```json { - "code": "not_found", - "message": "Course not found", - "data": [] + "code": "tutor_read_course", + "message": "Course retrieved successfully", + "data": { + "posts": [ + { + "ID": 105, + "post_title": "Modern JavaScript from Scratch", + "post_content": "Course description...", + "post_status": "publish", + "post_name": "modern-javascript-from-scratch", + "thumbnail_url": "http://tutor-dev.local/wp-content/uploads/course-js.jpg", + "price": "49.00", + "sale_price": "39.00", + "post_author": { + "ID": 1, + "display_name": "Jane Doe", + "user_nicename": "jane" + }, + "additional_info": { + "course_price_type": ["paid"], + "course_level": ["all_levels"], + "disable_qa": false + }, + "ratings": { + "rating_count": 12, + "rating_avg": 4.75, + "rating_sum": 57, + "count_by_value": { + "5": 9, + "4": 3, + "3": 0, + "2": 0, + "1": 0 + } + }, + "course_category": [], + "course_tag": [] + } + ], + "total_course": 25, + "total_page": 3 + } } ``` - ### 401 / 403 Forbidden + ### 404 Not Found ```json { - "code": "rest_forbidden", - "message": "Sorry, you are not allowed to do that.", + "code": "tutor_read_course", + "message": "Course not found", "data": { - "status": 401 + "status": 404 } } ``` diff --git a/docs/api/Curriculum/lessons.bru b/docs/api/Curriculum/lessons.bru index 6995d1a2e5..a209dc3e36 100644 --- a/docs/api/Curriculum/lessons.bru +++ b/docs/api/Curriculum/lessons.bru @@ -7,43 +7,48 @@ meta { get { url: {{base_url}}/lessons body: none - auth: basic + auth: bearer } params:query { - ~topic_id: 1 + topic_id: 1 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { - # Get Lessons by Topic + # GET /lessons - Retrieves all lessons belonging to a specific topic, including lesson descriptions, downloadable attachments, video player settings, and thumbnails. + Retrieves lessons for a topic. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/lessons` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **With Bearer JWT:** user must be able to view the parent course. + - **Without JWT:** allowed only when the parent course is **public**. + - Permission for JWT callers comes from the bound API key (`Read`, `Read/Write`, or `All`). ## Query Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `topic_id` | `integer` | **Yes** | The WordPress Post ID of the parent topic. | + | `topic_id` | `integer` | **Yes** | Parent topic post ID. Registered as a required REST arg; omitting it typically yields WordPress `rest_missing_callback_param` before the controller runs. | + + ## Notes — outline vs full DTO + + - **Outline** (guests / non-enrolled on locked courses): `ID`, `post_title`, `post_name`, `post_type`, optional `duration`. + - **Full** (enrolled / unlocked / instructor): `ID`, `post_title`, `post_content`, `post_name`, `topic_id`, `attachments`, `thumbnail`, `video`. ## Responses - ### 200 OK (Success) + ### 200 OK — full lesson (enrolled / unlocked) ```json { - "code": "success", + "code": "tutor_read_lesson", "message": "Lesson retrieved successfully", "data": [ { @@ -58,9 +63,19 @@ docs { "thumbnail": "http://tutor-dev.local/wp-content/uploads/lesson-thumb.jpg", "video": [ { - "source": "html5", - "source_video_id": 450, - "poster": "http://tutor-dev.local/wp-content/uploads/poster.jpg" + "source": "youtube", + "source_video_id": "", + "poster": "", + "source_external_url": "", + "source_shortcode": "", + "source_youtube": "https://www.youtube.com/watch?v=example", + "source_vimeo": "", + "source_embedded": "", + "runtime": { + "hours": 0, + "minutes": 10, + "seconds": 0 + } } ] } @@ -68,23 +83,45 @@ docs { } ``` - ### Error Responses + `video` is the stored `_video` post meta (`get_post_meta(..., false)`), so it is an **array** of the internal meta object (`source` + `source_youtube`, etc.) — not the Pro write-API `{ source_type, source }` shape. + + ### 200 OK — outline + ```json + { + "code": "tutor_read_lesson", + "message": "Lesson retrieved successfully", + "data": [ + { + "ID": 301, + "post_title": "1.1 Setting Up Your Development Workspace", + "post_name": "1-1-setting-up-workspace", + "post_type": "lesson", + "duration": "00:10:00" + } + ] + } + ``` - When `topic_id` is missing: + ### Missing `topic_id` (WordPress validation) ```json { - "code": "not_found", - "message": "topic_id is required", - "data": [] + "code": "rest_missing_callback_param", + "message": "Missing parameter(s): topic_id", + "data": { + "status": 400, + "params": ["topic_id"] + } } ``` - When no lessons exist for the topic ID: + ### No lessons ```json { - "code": "not_found", + "code": "tutor_read_lesson", "message": "Lesson not found for the given topic ID", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Curriculum/topics.bru b/docs/api/Curriculum/topics.bru index 60555c833e..9bdf0820c7 100644 --- a/docs/api/Curriculum/topics.bru +++ b/docs/api/Curriculum/topics.bru @@ -7,78 +7,102 @@ meta { get { url: {{base_url}}/topics body: none - auth: basic + auth: bearer } params:query { - ~course_id: 1 + course_id: 1 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { - # Get Topics by Course + # GET /topics - Retrieves all topic modules belonging to a specific course. + Retrieves topic modules for a course. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/topics` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **With Bearer JWT:** user must be able to view the course (enrolled / instructor / public). + - **Without JWT:** allowed only when the course is **public**. + - Permission for JWT callers comes from the bound API key (`Read`, `Read/Write`, or `All`). ## Query Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `course_id` | `integer` | **Yes** | The WordPress Post ID of the course. | + | `course_id` | `integer` | **Yes** | Parent course post ID. Registered as a required REST arg; omitting it typically yields WordPress `rest_missing_callback_param` before the controller runs. | + + ## Notes — outline vs full DTO + + - **Full:** when the caller may reveal learning payloads (enrolled / instructor / public free course), each topic is a public post DTO: `ID`, `post_title`, `post_content`, `post_name`. + - **Outline:** otherwise topics are outline-only: `ID`, `post_title`, `post_name` (no `post_content`). ## Responses - ### 200 OK (Success) + ### 200 OK — full topic bodies ```json { - "code": "get_topic", + "code": "tutor_read_topic", "message": "Topic retrieved successfully", "data": [ { - "ID": "201", + "ID": 201, "post_title": "Getting Started with the Environment", "post_content": "Detailed overview of tools, IDE plugins, and prerequisites.", "post_name": "getting-started-environment" - }, - { - "ID": "202", - "post_title": "Core Concepts & Deep Dive", - "post_content": "Deep exploration of architecture and API patterns.", - "post_name": "core-concepts-deep-dive" } ] } ``` - ### 404 / Missing Param Errors + ### Missing `course_id` (WordPress validation) + ```json + { + "code": "rest_missing_callback_param", + "message": "Missing parameter(s): course_id", + "data": { + "status": 400, + "params": ["course_id"] + } + } + ``` + + ### Invalid / non-course `course_id` + ```json + { + "code": "rest_invalid_resource", + "message": "Course not found.", + "data": { + "status": 404 + } + } + ``` - When `course_id` is missing: + ### Real course, no access ```json { - "code": "get_topic", - "message": "course_id is required", - "data": [] + "code": "rest_forbidden", + "message": "Sorry, you are not allowed to do that.", + "data": { + "status": 401 + } } ``` - When no topics exist for the provided ID: + ### No topics ```json { - "code": "not_found", + "code": "tutor_read_topic", "message": "Topic not found for given course ID", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Ecommerce & Webhooks/ecommerce-webhook-{payment-method}.bru b/docs/api/Ecommerce & Webhooks/ecommerce-webhook-{payment-method}.bru index f4b97062ba..fd2ee35e3b 100644 --- a/docs/api/Ecommerce & Webhooks/ecommerce-webhook-{payment-method}.bru +++ b/docs/api/Ecommerce & Webhooks/ecommerce-webhook-{payment-method}.bru @@ -42,13 +42,15 @@ docs { | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `payment_method` | `string` | No | Identifier slug of the payment gateway (e.g. `paypal`, `stripe`). | + | `payment_method` | `string` | **Effectively yes** | Gateway slug (e.g. `paypal`, `stripe`). The route allows an optional segment, but an empty method logs `"No Payment Method found from webhook url,"` and **exits** without a Tutor JSON body. | ## Request Body - Accepts raw webhook payload sent by the payment provider (JSON or form-urlencoded). + Accepts raw webhook payload sent by the payment provider (JSON or form-urlencoded). The handler also reads `$_GET`, `$_POST`, `$_SERVER`, and `php://input` for gateway verification. - ## Responses + ## Response behavior (no Tutor JSON envelope) - ### 200 OK (Webhook Processed) - Standard HTTP 200/204 response returned to notify the payment provider that the webhook event was received. + This endpoint does **not** return `{ code, message, data }`. Behavior depends on the gateway: + - Empty / missing `payment_method`: server-side `error_log` + `exit()` (no JSON envelope). + - Known gateway: `verify_webhook_signature`; on success may fire `tutor_order_payment_updated` and optionally `wp_safe_redirect` to a `redirectUrl`. + - Gateways typically expect a plain HTTP 200/204 acknowledgement rather than Tutor’s REST envelope. } diff --git a/docs/api/Instructors/author-information-{id}.bru b/docs/api/Instructors/author-information-{id}.bru index e759314dfe..3d6cef620d 100644 --- a/docs/api/Instructors/author-information-{id}.bru +++ b/docs/api/Instructors/author-information-{id}.bru @@ -7,64 +7,82 @@ 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 { - # Get Instructor Information + # GET /author-information/{id} - Retrieves public instructor/author details including display name, email, registration date, biographical info, and a list of courses authored by the instructor. + Retrieves public instructor profile fields and published course IDs. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/author-information/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **Public** when the user's public profile layout is not private (`permission_public_author`). + - Bearer JWT optional. Present but invalid access token → `rest_invalid_access_token`. + + ## Public vs private fields + + Always returned: + - `ID`, `display_name`, `user_nicename`, `courses` (published instructor course IDs) + + Added only when `RestAuth::can_view_user_private_fields( target_user_id )` is true (self, users with `list_users`, or instructor of a course the target is enrolled in): + - `user_login`, `user_email`, `user_registered`, `user_url` ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress User ID of the instructor/author. | + | `id` | `integer` | **Yes** | Instructor user ID. | ## Responses - ### 200 OK (Success) + ### 200 OK — public fields ```json { - "code": "success", + "code": "tutor_read_author", "message": "Author details retrieved successfully", "data": { - "ID": "1", - "user_login": "jane_instructor", + "ID": 1, + "display_name": "Jane Doe", "user_nicename": "jane_instructor", - "user_email": "jane@example.com", - "user_url": "https://example.com", - "user_registered": "2025-01-01 12:00:00", - "user_status": "0", + "courses": [105, 110, 120] + } + } + ``` + + ### 200 OK — with private fields + ```json + { + "code": "tutor_read_author", + "message": "Author details retrieved successfully", + "data": { + "ID": 1, "display_name": "Jane Doe", - "courses": [ - "105", - "110", - "120" - ] + "user_nicename": "jane_instructor", + "courses": [105, 110, 120], + "user_login": "jane", + "user_email": "jane@example.com", + "user_registered": "2024-01-01 00:00:00", + "user_url": "https://example.com" } } ``` - ### 404 / Invalid ID + ### 404 Not Found ```json { - "code": "invalid_id", + "code": "tutor_read_author", "message": "Author not found", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Quizzes/quiz-attempt-details-{id}.bru b/docs/api/Quizzes/quiz-attempt-details-{id}.bru index 054c48c281..dca67c00a1 100644 --- a/docs/api/Quizzes/quiz-attempt-details-{id}.bru +++ b/docs/api/Quizzes/quiz-attempt-details-{id}.bru @@ -7,66 +7,70 @@ 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 { - # Get Quiz Attempt Details + # GET /quiz-attempt-details/{id} - Retrieves historical attempt records, passing status, marks obtained, and earned grades for a given quiz ID. + Retrieves attempt records for a quiz. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/quiz-attempt-details/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **Requires** Bearer JWT. + - Students see only their own attempts; instructors/admins with course access see all. + - Permission from the bound API key: `Read`, `Read/Write`, or `All`. ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the quiz. | + | `id` | `integer` | **Yes** | Quiz post ID. | ## Responses ### 200 OK (Success) ```json { - "code": "success", - "message": "Quiz attempt detail retrieved successfully", + "code": "tutor_read_quiz", + "message": "Quiz attempts retrieved successfully", "data": [ { - "attempt_id": 88, - "course_id": 105, - "quiz_id": 501, - "user_id": 14, - "total_questions": 10, - "total_answered_questions": 10, + "attempt_id": "88", + "user_id": "14", + "total_questions": "10", + "total_answered_questions": "10", "total_marks": "100.00", "earned_marks": "90.00", - "attempt_status": "review_required", - "attempt_info": "...", + "attempt_status": "attempt_ended", + "attempt_info": {}, "attempt_started_at": "2026-02-01 14:20:00", "attempt_ended_at": "2026-02-01 14:45:00", - "is_manually_reviewed": 0 + "is_manually_reviewed": "0", + "manually_reviewed_at": null, + "attempts_answer": [] } ] } ``` + Attempt rows do not include `course_id` / `quiz_id` in the SQL select. Nested answers are under `attempts_answer`; `is_correct` may be hidden for students when quiz details/answer keys are closed. + ### 404 Not Found ```json { - "code": "not_found", - "message": "Quiz attempt detail not found for given ID", - "data": [] + "code": "tutor_read_quiz", + "message": "Quiz attempts not found for given ID", + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Quizzes/quiz-question-answer-{id}.bru b/docs/api/Quizzes/quiz-question-answer-{id}.bru index 1cfd89abe1..bd9983d70d 100644 --- a/docs/api/Quizzes/quiz-question-answer-{id}.bru +++ b/docs/api/Quizzes/quiz-question-answer-{id}.bru @@ -7,71 +7,82 @@ 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 { - # Get Quiz Question Answers + # GET /quiz-question-answer/{id} - Retrieves a flattened list of all questions and their configured answers for a given quiz ID. + Retrieves questions for a quiz, each with nested `question_answers`. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/quiz-question-answer/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **Requires** Bearer JWT. + - User must be able to view the parent course. + - Permission from the bound API key: `Read`, `Read/Write`, or `All`. ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the quiz. | + | `id` | `integer` | **Yes** | Quiz post ID. | + + ## Notes + + `question_settings` is unserialized from the questions table. Correctness may be redacted the same way as `GET /quizzes/{id}` when the caller cannot reveal quiz answers. ## Responses ### 200 OK (Success) ```json { - "code": "success", - "message": "Quiz question and answer retrieved successfully", + "code": "tutor_read_quiz", + "message": "Question retrieved successfully", "data": [ { - "question_id": 12, + "question_id": "12", "question_title": "Which HTTP method is idempotent?", "question_description": "Select the correct answer.", "question_type": "single_choice", - "question_mark": 1, - "answer_id": 45, - "answer_title": "GET", - "is_correct": 1 - }, - { - "question_id": 12, - "question_title": "Which HTTP method is idempotent?", - "question_description": "Select the correct answer.", - "question_type": "single_choice", - "question_mark": 1, - "answer_id": 46, - "answer_title": "POST", - "is_correct": 0 + "question_mark": "1", + "question_settings": { + "answer_required": "1", + "randomize_question": "0" + }, + "question_answers": [ + { + "answer_id": "45", + "answer_title": "GET", + "is_correct": "1" + }, + { + "answer_id": "46", + "answer_title": "POST", + "is_correct": "0" + } + ] } ] } ``` + This is a list of questions with nested answers (not a flattened question+answer row list). + ### 404 Not Found ```json { - "code": "not_found", - "message": "Quiz question not found for given ID", - "data": [] + "code": "tutor_read_quiz", + "message": "Question not found for given ID", + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Quizzes/quizzes-{id}.bru b/docs/api/Quizzes/quizzes-{id}.bru index 3a88cfca42..79d5c8ae6c 100644 --- a/docs/api/Quizzes/quizzes-{id}.bru +++ b/docs/api/Quizzes/quizzes-{id}.bru @@ -7,39 +7,54 @@ 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 { - # Get Quiz Details + # GET /quizzes/{id} - Retrieves complete details for a specific quiz, including all questions, question types, point values, descriptions, and answer options. + Retrieves a quiz with settings and questions. ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/quizzes/:id` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **Requires** Bearer JWT. + - User must be able to view the parent course. + - Permission from the bound API key: `Read`, `Read/Write`, or `All`. ## Path Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `id` | `integer` | **Yes** | The WordPress Post ID of the quiz. | + | `id` | `integer` | **Yes** | Quiz post ID. | + + ## Notes + + ### Response fields (`REST_Quiz::get_quiz`) + - Public post DTO: `ID`, `post_title`, `post_content`, `post_name` + - `quiz_settings`: from quiz post meta `tutor_quiz_option` (`get_post_meta(..., false)` → array) + - `quiz_questions`: rows from `tutor_quiz_questions` with `question_id`, `question_title`, `question_description`, `question_type`, `question_mark`, `question_settings`, `question_answers` + - `question_settings` is selected from the questions table column. Current PHP does not reliably unserialize it on this endpoint (unlike `GET /quiz-question-answer/{id}`, which calls `maybe_unserialize` on each question). Expect a serialized string or already-unserialized array depending on storage. + + ### Answer redaction (students) + When `RestAuth::can_reveal_quiz_answers` is false: + - Choice types (`true_false`, `single_choice`, `multiple_choice`): keep option titles; strip `is_correct` and `answer_two_gap_match` + - Other types: `question_answers` is emptied + + Instructors / admins with course content access, or students after a finished attempt when “Hide Quiz Details” is off, may still receive answer keys. ## Responses - ### 200 OK (Success) + ### 200 OK (instructor / answers revealed) ```json { - "code": "success", + "code": "tutor_read_quiz", "message": "Quiz retrieved successfully", "data": { "ID": 501, @@ -56,27 +71,24 @@ docs { "passing_grade": "80" } ], - "questions": [ + "quiz_questions": [ { - "question_id": 12, + "question_id": "12", "question_title": "Which HTTP method is idempotent?", "question_description": "Select the correct answer.", "question_type": "single_choice", - "question_mark": 1, - "question_settings": { - "answer_required": "1", - "randomize_question": "0" - }, - "answers": [ + "question_mark": "1", + "question_settings": "a:2:{s:15:\"answer_required\";s:1:\"1\";s:18:\"randomize_question\";s:1:\"0\";}", + "question_answers": [ { - "answer_id": 45, + "answer_id": "45", "answer_title": "GET", - "is_correct": 1 + "is_correct": "1" }, { - "answer_id": 46, + "answer_id": "46", "answer_title": "POST", - "is_correct": 0 + "is_correct": "0" } ] } @@ -85,12 +97,16 @@ docs { } ``` + Field names are `quiz_questions` and `question_answers` (not `questions` / `answers`). + ### 404 Not Found ```json { - "code": "not_found", + "code": "tutor_read_quiz", "message": "Quiz not found for given ID", - "data": [] + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/Quizzes/quizzes.bru b/docs/api/Quizzes/quizzes.bru index 5ac2fb7793..ddb15b0341 100644 --- a/docs/api/Quizzes/quizzes.bru +++ b/docs/api/Quizzes/quizzes.bru @@ -7,51 +7,50 @@ meta { get { url: {{base_url}}/quizzes body: none - auth: basic + auth: bearer } params:query { - ~topic_id: 1 + topic_id: 1 } -auth:basic { - username: {{api_key}} - password: {{api_secret}} +auth:bearer { + token: {{access_token}} } docs { - # Get Quizzes by Topic + # GET /quizzes - Retrieves all quizzes configured under a specific topic module, including quiz settings (time limits, passing grades, attempt limits). + Retrieves quizzes under a topic (with quiz settings meta). ## HTTP Method & Route - **Method:** `GET` - **Route:** `/wp-json/tutor/v1/quizzes` ## Authentication - - **Type:** Basic Auth (`api_key` / `api_secret`) - - **Required Permission:** `Read` or `All` + - **Requires** Bearer JWT (always authenticated). + - User must be able to view the parent course. + - Permission from the bound API key: `Read`, `Read/Write`, or `All`. ## Query Parameters | Parameter | Type | Required | Description | | :--- | :--- | :--- | :--- | - | `topic_id` | `integer` | **Yes** | The WordPress Post ID of the parent topic. | + | `topic_id` | `integer` | **Yes** | Parent topic post ID. Required REST arg; omitting it typically yields `rest_missing_callback_param`. | ## Responses ### 200 OK (Success) ```json { - "code": "success", + "code": "tutor_read_quiz", "message": "Quiz retrieved successfully", "data": [ { "ID": 501, "post_title": "Module 1 Knowledge Check", - "post_content": "Short 10-question evaluation of module 1 concepts.", + "post_content": "Short evaluation of module 1 concepts.", "post_name": "module-1-knowledge-check", - "topic_id": 201, "quiz_settings": [ { "time_limit": { @@ -68,12 +67,16 @@ docs { } ``` - ### 404 / Missing Param Errors + Each item is a public post DTO (`ID`, `post_title`, `post_content`, `post_name`) plus `quiz_settings` from `get_post_meta(..., 'tutor_quiz_option', false)` (array of settings objects). + + ### 404 Not Found ```json { - "code": "not_found", - "message": "topic_id is required", - "data": [] + "code": "tutor_read_quiz", + "message": "Quiz not found for given ID", + "data": { + "status": 404 + } } ``` } diff --git a/docs/api/README.md b/docs/api/README.md index 8872195789..f37a796fa3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -4,45 +4,73 @@ 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` in Free; Pro also offers `Write`, `Delete`, `Read/Write`, and `All`). **Copy the secret immediately and store it securely — it is shown only once and cannot be retrieved again.** +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 (use the secret copied at generation time) + - `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}}`. + +`local.bru` is gitignored so credentials are not committed. For production, configure `environments/production.bru`. -## 📁 Collection Structure +### Auth flow + +``` +Login only: + Tutor-Api-Key + Tutor-Api-Secret + username/password + │ + ▼ + POST /auth/login → access_token + refresh_token + │ + ▼ +Other Tutor REST routes (when authenticated): + Authorization: Bearer (or Tutor-User-Token header) + │ + ├── POST /auth/refresh (body: refresh_token) when access expires + └── POST /auth/logout (invalidate refresh token(s)) +``` -The collection is organized into the following categories: +**Auth clarification** -- **`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. +| Route | Credentials | +| :-------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /auth/login` | `Tutor-Api-Key` + `Tutor-Api-Secret` headers + username/password body | +| All other Tutor REST routes | Bearer JWT only (`Authorization: Bearer …` or `Tutor-User-Token`). Permission is taken from the API key id (`kid`) bound into the access token. | +| `POST /auth/refresh` | Refresh token in body (no API key / Bearer required) | +| `POST /auth/logout` | Optional Bearer and/or `refresh_token` / `all` in body | --- -## 💻 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 +83,103 @@ 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 +## API Response Format -All Tutor LMS REST API endpoints return a standardized JSON envelope: +Successful Tutor REST responses use an operation-specific `code` (not a generic `"success"`): ```json { - "code": "success", + "code": "tutor_read_course", "message": "Human readable message", - "data": { ... } + "data": {} +} +``` + +Examples of operation codes: `tutor_read_course`, `tutor_read_rating`, `tutor_read_quiz`, `tutor_auth_login`, `tutor_auth_refresh`, `tutor_auth_logout`. + +### Error shape (`WP_Error`) + +HTTP status ≥ 400 responses are WordPress `WP_Error` objects. The REST API serializes them as: + +```json +{ + "code": "tutor_read_course", + "message": "Course not found", + "data": { + "status": 404 + } +} +``` + +When the controller passes extra error details, they appear under `data.details`: + +```json +{ + "code": "tutor_validation_error", + "message": "Invalid input", + "data": { + "status": 400, + "details": { "field": "…" } + } } ``` -### 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. +WordPress may also return core codes such as `rest_missing_callback_param` (missing required args) with `data.status` and `data.params`. + +### Access token errors (authenticated routes) + +Protected Tutor routes authorize via `RestAuth::authorize_authenticated_request()` / `get_access_token_error()`. Missing or bad Bearer JWTs return explicit codes (HTTP status from `rest_authorization_required_code()` — **401** when no WP user is authenticated, **403** if a session user is already present): + +**Missing / empty access token** + +```json +{ + "code": "rest_missing_access_token", + "message": "Access token is required.", + "data": { + "status": 401 + } +} +``` + +**Invalid, expired, or revoked access token** + +```json +{ + "code": "rest_invalid_access_token", + "message": "Invalid access token.", + "data": { + "status": 401 + } +} +``` + +**Valid token, insufficient API key permission** + +```json +{ + "code": "rest_forbidden", + "message": "API key does not have permission for this request.", + "data": { + "status": 401 + } +} +``` + +Send `Authorization: Bearer {{access_token}}` (or `Tutor-User-Token`) on authenticated routes. Run **Authentication → Login** (or **Refresh Token**) when you see these errors. + +### HTTP status codes + +- `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, 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 + +`POST|GET|… /ecommerce-webhook/{payment_method}` does **not** use the Tutor JSON envelope. It is a public gateway callback: empty `payment_method` logs and exits; otherwise the gateway driver verifies the signature and may redirect. Prefer treating `payment_method` as effectively required. diff --git a/docs/api/environments/Tutorlms.bru b/docs/api/environments/Tutorlms.bru new file mode 100644 index 0000000000..0ea2e5cf5b --- /dev/null +++ b/docs/api/environments/Tutorlms.bru @@ -0,0 +1,7 @@ +vars { + base_url: https://tutorlms.test.com/wp-json/tutor/v1 + access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTc5MTg3NjIzNywiaXNzIjoidHV0b3IiLCJ0diI6NCwia2lkIjoxODgxLCJleHAiOjE3OTE5NjI2Mzd9.ZKLpUajAAdTKNM5BaTDn1ra80ltM0aNTDc33z1B7wvk + api_key: key_c3d27b854fcaf276c9b1e898a8ca4dd5 + api_secret: secret_158a1058a1b245689fd0cbfd70e84ace340fb96fd9313e0afb7c16e18b3e8b04 + refresh_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTc5MTg3NjIzNywiaXNzIjoidHV0b3IiLCJ0eXAiOiJyZWZyZXNoIiwia2lkIjoxODgxLCJleHAiOjE3OTQ0NjgyMzd9.0eoJDkFtYiM6PQ66lmrG_ECaLR0H78tKp-5hA4Y5iOI +} 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 ] diff --git a/docs/developer/core/DB.php.md b/docs/developer/core/DB.php.md new file mode 100644 index 0000000000..01d739a0e5 --- /dev/null +++ b/docs/developer/core/DB.php.md @@ -0,0 +1,513 @@ +# DB Helper (`Tutor\Core\DB`) + +- **Class:** `Tutor\Core\DB` +- **File:** `wp-content/plugins/tutor/core/DB.php` +- **Namespace:** `Tutor\Core` +- **Since:** `4.2.0` +- **Package:** `Tutor\Core` + +--- + +## 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\Core\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 (supports 4-argument syntax or closure for multiple ON conditions) + public function join( string $table, $first, string $operator = '', string $second = '' ): self; + public function left_join( string $table, $first, string $operator = '', string $second = '' ): self; + public function right_join( string $table, $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 & Validation 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; + public static function parse_column_alias( string $expression ): ?string; + public static function is_valid_column( string $column ): bool; + public static function is_valid_identifier( string $identifier ): bool; + public static function is_valid_operator( string $operator ): bool; +} +``` + +--- + +## 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'); +``` + +#### Column Aliases +Column aliases are fully supported using uppercase `AS`, lowercase `as`, or implicit space separation: + +```php +DB::table('orders') + ->select('id AS order_id', 'parent_id as parent_order_id', 'name person_name'); + +// With table prefixes: +DB::table('users') + ->as('u') + ->select('u.id AS user_id', 'u.user_login as username'); +``` + +> [!NOTE] +> All column names and alias identifiers are individually validated against strict regex patterns (`is_valid_column` and `is_valid_identifier`) to prevent SQL injection. + +### `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 support two distinct styles: +1. **Standard 4-argument syntax:** Table, first column, operator, second column. +2. **Closure syntax:** For advanced joins with multiple `ON` conditions chained via `on()` or `or_on()`. + +### Supported Join Methods: +- `join( string $table, $first, string $operator = '', string $second = '' )` — `INNER JOIN` +- `left_join( string $table, $first, string $operator = '', string $second = '' )` — `LEFT JOIN` +- `right_join( string $table, $first, string $operator = '', string $second = '' )` — `RIGHT JOIN` + +### 1. Standard 4-Argument Syntax +```php +DB::table('tutor_earnings') + ->as('e') + ->select('e.*', 'u.user_login') + ->left_join("{$wpdb->users} AS u", 'e.user_id', '=', 'u.ID') + ->get(); +``` + +### 2. Closure Syntax for Multiple ON Conditions +Pass a closure accepting the `$join` collector as the second argument. The closure supports chaining multiple `on()` and `or_on()` conditions: + +```php +$users = DB::table('users') + ->join('contacts', function ($join) { + $join->on('users.id', '=', 'contacts.user_id') + ->on('users.status', '=', 'contacts.status'); + }) + ->select('users.*', 'contacts.phone') + ->get(); +``` + +#### Closure Methods: +- `$join->on( $first, $operator, $second )` — Combines condition with `AND` (default). +- `$join->on( $first, $second )` — 2-argument shortcut defaulting the operator to `=`. +- `$join->or_on( $first, $operator, $second )` — Combines condition with `OR`. +- `$join->or_on( $first, $second )` — 2-argument shortcut with `OR`. + +```php +// Chaining with OR conditions and table aliases: +DB::table('users') + ->left_join('contacts AS c', function ($join) { + $join->on('users.id', '=', 'c.user_id') + ->or_on('users.email', '=', 'c.email'); + }) + ->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_.*]*$/`. Column aliases in `select()` are parsed and verified against strict column and alias patterns (`is_valid_column` and `is_valid_identifier`). Join conditions (including closure-based `on()` and `or_on()` conditions) validate both columns and operators against allowlists before inclusion. Characters that could trigger SQL injection (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. diff --git a/ecommerce/AdminMenu.php b/ecommerce/AdminMenu.php index 658aba323b..8700159a36 100644 --- a/ecommerce/AdminMenu.php +++ b/ecommerce/AdminMenu.php @@ -10,7 +10,7 @@ namespace Tutor\Ecommerce; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\OrderModel; /** diff --git a/ecommerce/CartController.php b/ecommerce/CartController.php index 38c9a0058b..46f5036bdd 100644 --- a/ecommerce/CartController.php +++ b/ecommerce/CartController.php @@ -12,7 +12,7 @@ use TUTOR\Course; use Tutor\Helpers\HttpHelper; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\CartModel; use Tutor\Models\CourseModel; use Tutor\Traits\JsonResponse; diff --git a/ecommerce/CheckoutController.php b/ecommerce/CheckoutController.php index 7172c2b4d9..031e03fc92 100644 --- a/ecommerce/CheckoutController.php +++ b/ecommerce/CheckoutController.php @@ -13,7 +13,7 @@ defined( 'ABSPATH' ) || exit; use Tutor\GDPR\Controllers\LegalConsent; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\CartModel; use Tutor\Models\OrderModel; use Tutor\Models\CouponModel; @@ -624,6 +624,7 @@ public function pay_now() { } } + //phpcs:ignore $validate_consent = LegalConsent::validate_consent( LegalConsent::DISPLAY_ON_CHECKOUT, $_POST ); if ( is_wp_error( $validate_consent ) ) { array_push( $errors, $validate_consent->get_error_message() ); @@ -645,20 +646,16 @@ public function pay_now() { array_push( $errors, __( 'Invalid cart items', 'tutor' ) ); } elseif ( OrderModel::TYPE_SINGLE_ORDER === $order_type ) { foreach ( $object_ids as $object_id ) { - $can_buy = apply_filters( 'tutor_can_purchase_course', true, $object_id ); - if ( is_wp_error( $can_buy ) ) { - array_push( $errors, $can_buy->get_error_message() ); + $can_purchase = apply_filters( 'tutor_can_purchase_course', true, $object_id ); + if ( is_wp_error( $can_purchase ) ) { + array_push( $errors, $can_purchase->get_error_message() ); } } } elseif ( OrderModel::TYPE_SUBSCRIPTION === $order_type ) { - $item_id = $object_ids[0] ?? 0; - if ( $item_id ) { - $plan = apply_filters( 'tutor_get_plan_info', null, $item_id ); - if ( ! $plan ) { - array_push( $errors, __( 'Invalid plan', 'tutor' ) ); - } - } else { - array_push( $errors, __( 'Invalid plan', 'tutor' ) ); + $plan_id = absint( $object_ids[0] ?? 0 ); + $can_purchase = apply_filters( 'tutor_can_purchase_plan', true, $plan_id ); + if ( is_wp_error( $can_purchase ) ) { + array_push( $errors, $can_purchase->get_error_message() ); } } else { array_push( $errors, __( 'Invalid order type', 'tutor' ) ); @@ -767,6 +764,20 @@ public function pay_now() { } if ( ! empty( $order_data ) ) { + $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 ); + 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 { @@ -887,6 +898,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, @@ -922,7 +934,7 @@ public static function prepare_payment_data( array $order ) { public static function prepare_recurring_payment_data( int $order_id ) { $order_data = ( new OrderModel() )->get_order_by_id( $order_id ); if ( ! $order_data ) { - throw new \Exception( __( 'Order not found!', 'tutor' ) ); + throw new \Exception( esc_html( __( 'Order not found!', 'tutor' ) ) ); } $amount = $order_data->total_price; @@ -976,6 +988,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, ); @@ -1178,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 371d1b16fa..794284a829 100644 --- a/ecommerce/CouponController.php +++ b/ecommerce/CouponController.php @@ -16,7 +16,7 @@ use Tutor\Helpers\DateTimeHelper; use Tutor\Helpers\HttpHelper; use Tutor\Helpers\ValidationHelper; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\CouponModel; use Tutor\Models\CourseModel; use Tutor\Models\OrderModel; @@ -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/Ecommerce.php b/ecommerce/Ecommerce.php index e5f38be386..dab2dc24f0 100644 --- a/ecommerce/Ecommerce.php +++ b/ecommerce/Ecommerce.php @@ -12,7 +12,7 @@ use TUTOR\Course; use Tutor\Ecommerce\PaymentHandler; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\CouponModel; use Tutor\PaymentGateways\Configs\PaypalConfig; use Tutor\PaymentGateways\GatewayFactory; diff --git a/ecommerce/HooksHandler.php b/ecommerce/HooksHandler.php index 8a29f9886a..2d65b0f8cd 100644 --- a/ecommerce/HooksHandler.php +++ b/ecommerce/HooksHandler.php @@ -10,12 +10,13 @@ namespace Tutor\Ecommerce; -use TUTOR\Input; +use Tutor\Core\Input; use TUTOR\Course; use TUTOR\Earnings; 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' ) ); } /** @@ -197,7 +199,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; @@ -268,8 +270,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 ); + } } /** @@ -443,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}" ); } } } @@ -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; @@ -597,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 ); + } + } } diff --git a/ecommerce/OrderController.php b/ecommerce/OrderController.php index ecb0bd266f..211ae727ff 100644 --- a/ecommerce/OrderController.php +++ b/ecommerce/OrderController.php @@ -10,7 +10,7 @@ namespace Tutor\Ecommerce; -use TUTOR\Input; +use Tutor\Core\Input; use TUTOR\Earnings; use Tutor\Models\OrderModel; use TUTOR\Backend_Page_Trait; 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/Exceptions/HttpRequestException.php b/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php new file mode 100644 index 0000000000..5a3eeb4c86 --- /dev/null +++ b/ecommerce/PaymentGateways/Exceptions/HttpRequestException.php @@ -0,0 +1,72 @@ +response = $response; + } + + /** + * Get the response, or null for transport errors. + * + * @since 4.2.0 + * + * @return HttpHelper|null + */ + public function get_response(): ?HttpHelper { + return $this->response; + } + + /** + * Determine if a response was received. + * + * @since 4.2.0 + * + * @return bool + */ + public function has_response(): bool { + return ! is_null( $this->response ); + } +} diff --git a/ecommerce/PaymentGateways/Exceptions/InvalidDataException.php b/ecommerce/PaymentGateways/Exceptions/InvalidDataException.php new file mode 100644 index 0000000000..069324d661 --- /dev/null +++ b/ecommerce/PaymentGateways/Exceptions/InvalidDataException.php @@ -0,0 +1,26 @@ +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, $args ); + break; + + case HttpHelper::METHOD_POST: + $response = HttpHelper::post( $url, $body, $headers, $args ); + break; + + case HttpHelper::METHOD_PUT: + $response = HttpHelper::put( $url, $body, $headers, $args ); + break; + + case HttpHelper::METHOD_PATCH: + $response = HttpHelper::patch( $url, $body, $headers, $args ); + break; + + case HttpHelper::METHOD_DELETE: + $response = HttpHelper::delete( $url, $body, $headers, $args ); + break; + + default: + $error_message = sprintf( + /* translators: %s: HTTP method name */ + __( 'Unsupported HTTP method: %s', 'tutor' ), + $method + ); + throw new \InvalidArgumentException( esc_html( $error_message ) ); + } + + // Transport failure (DNS, timeout, TLS): there is no response to attach. + if ( $response->has_error() ) { + throw new HttpRequestException( esc_html( $response->get_error_message() ) ); + } + + $status = (int) $response->get_status_code(); + + // Attach the response so callers can read the gateway's error body. + if ( $status >= 400 ) { + throw new HttpRequestException( esc_html( sprintf( 'HTTP %d returned from %s', $status, $url ) ), $status, $response ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + if ( $return_raw_payload ) { + return $response; + } + + return $response->get_json(); + } +} 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/Core/Support/System.php b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php index 9187ac0bcc..d564f0768f 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php +++ b/ecommerce/PaymentGateways/Paypal/src/Core/Support/System.php @@ -1,15 +1,27 @@ 'payment', - 'id' => null, - 'payment_status' => 'unpaid', - 'payment_error_reason' => '', - 'transaction_id' => '', - 'payment_method' => '', - 'payment_payload' => '', - 'tax_amount' => '', - 'fees' => '', - 'earnings' => '', - ); - - } elseif ( $type === 'refund' ) { - $returnData = (object) array( - 'type' => 'refund', - 'id' => null, - 'refund_status' => '', - 'refund_id' => '', - 'payment_method' => '', - 'refund_amount' => '', - 'refund_error_reason' => '', - 'refund_payload' => '', - ); - } - - return $returnData; - } - - - /** - * Validates and sanitizes an email address. - * - * This method takes an email address as input, sanitizes it to remove any illegal - * characters or sequences, and then validates it against the standard email format. - * If the email address fails either sanitization or validation, an InvalidDataException - * is thrown with an appropriate error message. - * - * @param string $email The email address to be validated and sanitized. - * @return string The sanitized email address if valid. - * @throws InvalidDataException If the email address is invalid according to `FILTER_SANITIZE_EMAIL` - * or `FILTER_VALIDATE_EMAIL` filters. - * @since 1.0.0 - */ - public static function validateAndSanitizeEmailAddress( string $email ) { - $sanitizeEmail = filter_var( $email, FILTER_SANITIZE_EMAIL ); - $validateEmail = filter_var( $email, FILTER_VALIDATE_EMAIL ); - - if ( ! $sanitizeEmail || ! $validateEmail ) { - throw new InvalidDataException( 'Invalid Email Address' ); - } - - return $email; - } - - /** - * Extracts the first and last name parts from a full name string. - * - * @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 - */ - public static function extractNameParts( $name ): array { - if ( empty( $name ) ) { - return array(); - } - - // Trim leading and trailing spaces, split the full name into an array, and remove empty elements - $nameParts = array_filter( explode( ' ', trim( $name ) ) ); - // Extract the last name. - $lastName = array_pop( $nameParts ); - // Extract the first name - $firstName = implode( ' ', $nameParts ); - - return array( $firstName, $lastName ); - } - - /** - * 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. - * @return array The formatted part of the street address. - * @since 1.0.0 - */ - public static function splitAddress( $data, $maxLength ) { - if ( empty( $data->address1 ) ) { - return array(); - } - - $address_1 = mb_strimwidth( $data->address1, 0, $maxLength ); - $address_2 = ( strlen( $data->address1 ) > $maxLength ) ? mb_strimwidth( $data->address1, $maxLength, $maxLength ) : $data->address2; - - return array( $address_1, $address_2 ); - } - - /** - * Converts a major currency amount to its minor unit. - * - * @param float|string $amount The major currency amount to convert. - * @param string $currency The currency code to use for the conversion. - * - * @return int|null Returns the minor currency amount as an integer, or null if the amount is invalid. - * @since 1.0.0 - */ - public static function getMinorAmountBasedOnCurrency( $amount, $currency ) { - if ( ! is_null( $amount ) || ! empty( $amount ) ) { - return Money::of( (float) $amount, $currency, null, RoundingMode::HALF_UP )->getMinorAmount()->toInt(); - } - - return null; - } - - /** - * Converts a minor currency amount to its major unit equivalent. - * - * @param int|string $amount The minor currency amount to convert. - * @param string $currency The currency code to use for the conversion. - * - * @return float|null Returns the major currency amount as a float, or null if the amount is invalid. - * @since 1.0.0 - */ - public static function convertMinorAmountToMajor( $amount, $currency ) { - if ( ! is_null( $amount ) || ! empty( $amount ) ) { - return Money::ofMinor( $amount, $currency, null, RoundingMode::HALF_UP )->getAmount()->toFloat(); - } - - return null; - } - - /** - * Determines if the total amount is zero or not. - * - * @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 - */ - public static function isTotalAmountZero( &$data ): bool { - $data->subtotal ??= 0; - $data->tax ??= 0; - $data->shipping_charge ??= 0; - $data->coupon_discount ??= 0; - - $totalAmount = ( $data->subtotal + $data->tax + $data->shipping_charge ) - $data->coupon_discount; - - return empty( filter_var( $totalAmount, FILTER_VALIDATE_FLOAT ) ) ? true : false; - } - - /** - * Updates and returns a webhook URL by encoding success and cancel URLs as parameters. - * - * @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 - */ - public static function updateWebhookUrl( $config ): string { - $encodedUrlData = base64_encode( - wp_json_encode( - array( - 'success_url' => $config->get( 'success_url' ), - 'cancel_url' => $config->get( 'cancel_url' ), - ) - ) - ); - - $webhookUrl = Uri::getInstance( $config->get( 'webhook_url' ) ); - $webhookUrl->setVar( 'encodedData', $encodedUrlData ); - $webhookUrl->setVar( 'payment_method', $config->get( 'name' ) ); - - return $webhookUrl->__toString(); - } - - /** - * 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). - * @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 ); - - return json_decode( $response->getBody() ); - } } diff --git a/ecommerce/PaymentGateways/Paypal/src/Exceptions/InvalidDataException.php b/ecommerce/PaymentGateways/Paypal/src/Exceptions/InvalidDataException.php deleted file mode 100644 index 002ca70421..0000000000 --- a/ecommerce/PaymentGateways/Paypal/src/Exceptions/InvalidDataException.php +++ /dev/null @@ -1,9 +0,0 @@ -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' ), + '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, ), ); - $response = System::sendHttpRequest( $requestData ); + $response = Http::send( $requestData ); if ( $response->token_type && $response->access_token ) { $this->accessToken = "{$response->token_type} {$response->access_token}"; @@ -87,26 +98,29 @@ 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 { 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, ), ); - return System::sendHttpRequest( $request_data ); + return Http::send( $request_data ); } @@ -114,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 ) { @@ -125,19 +141,22 @@ 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 ); + Http::send( $requestData ); } /** * 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. @@ -157,16 +176,16 @@ 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, ), ); - $response_data = System::sendHttpRequest( $request_data ); + $response_data = Http::send( $request_data ); return 'SUCCESS' === $response_data->verification_status ? true : false; } catch ( \Throwable $error ) { @@ -185,16 +204,13 @@ public static function respond_to_event(): void { http_response_code( 200 ); echo 'OK'; - - if ( function_exists( 'fastcgi_finish_request' ) ) { - fastcgi_finish_request(); - } } /** * Retrieve PayPal order details using the provided API URL. * * @since 3.9.0 + * @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. @@ -203,15 +219,17 @@ 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 ); + return Http::send( $request_data ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $error_message ) ); } @@ -221,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. @@ -236,16 +255,16 @@ 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, ), ); - System::sendHttpRequest( $request_data ); + Http::send( $request_data ); } catch ( \Throwable $th ) { throw $th; } @@ -255,6 +274,7 @@ public static function refund( $refund_url, $order_id, $data ) { * Retrieve PayPal vault details from a given URL. * * @since 3.9.0 + * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. * * @param string $url The PayPal vault API URL to retrieve details. * @@ -265,17 +285,48 @@ 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 ); + return Http::send( $request_data ); - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $error_message = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( $error_message ); //phpcs:ignore } } + + /** + * Retrieves the refund status based on the provided links and type. + * + * @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 ); + + $requestData = array( + 'url' => $url, + 'options' => array( + 'headers' => self::$headers, + 'method' => HttpHelper::METHOD_GET, + ), + ); + + $responseData = Http::send( $requestData ); + + return strtolower( $responseData->status ) ?? null; + } } diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php index 4efbd11607..6382bdcf12 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Helper.php @@ -3,13 +3,16 @@ namespace Ollyo\PaymentHub\Payments\Paypal; use Ollyo\PaymentHub\Core\Support\Path; -use Ollyo\PaymentHub\Core\Support\System; -use GuzzleHttp\Exception\RequestException; use Ollyo\PaymentHub\Contracts\Config\RepositoryContract; - +use Tutor\PaymentGateways\Exceptions\HttpRequestException; +use Tutor\PaymentGateways\Utils; /** * Paypal Helper Class + * + * Builds PayPal request data and formats PayPal error responses. + * + * @since 3.0.0 */ final class Helper { @@ -19,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; @@ -45,7 +49,7 @@ function ( $item ) use ( $currency, $data ) { (array) $data->items ); - $minChargeApplicable = System::isTotalAmountZero( $data ); + $minChargeApplicable = Utils::isTotalAmountZero( $data ); if ( $minChargeApplicable ) { $items[] = array( @@ -174,16 +178,17 @@ 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 + * @since 4.2.0 Accepts HttpRequestException instead of Guzzle's RequestException. */ 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 ); @@ -257,9 +262,10 @@ 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] = System::splitAddress( $shipping, 300 ); + [$address1, $address2] = Utils::splitAddress( $shipping, 300 ); return array( 'type' => 'SHIPPING', diff --git a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php index 83e2f19ab2..a5cac57fe3 100644 --- a/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php +++ b/ecommerce/PaymentGateways/Paypal/src/Payments/Paypal/Paypal.php @@ -5,13 +5,20 @@ use Throwable; 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 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 { /** @@ -23,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'; @@ -67,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 { @@ -111,24 +136,32 @@ 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 { - if ( empty( $data ) ) { - return 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 = Utils::prepareMerchantMetadata( $data->order_user_id, $this->config->get( 'mode' ) ); + $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', @@ -158,6 +191,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.2.0 Catches HttpRequestException, logs the error and shows a generic message. */ public function createPayment() { try { @@ -170,10 +204,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,8 +221,9 @@ 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 \Exception If a PayPal API request fails. * @since 3.0.0 + * @since 4.2.0 Catches HttpRequestException instead of Guzzle's RequestException. */ public function verifyAndCreateOrderData( object $payload ): object { try { @@ -219,7 +255,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(); @@ -237,9 +273,10 @@ 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 = System::defaultOrderData(); + $returnData = Utils::defaultOrderData(); $statusMap = array( 'DECLINED' => 'failed', @@ -266,14 +303,16 @@ 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.2.0 Catches HttpRequestException, logs the error and shows a generic message. */ 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' ) ); } } @@ -313,117 +352,14 @@ 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.2.0 Catches HttpRequestException instead of Guzzle's RequestException. */ public function createRefund() { try { Api::refund( $this->refundLink, $this->orderID, $this->getData() ); - } catch ( RequestException $error ) { - $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); - throw new ErrorException( esc_html( $errorMessage ) ); - } - } - - /** - * 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. - * - * @return object|null Returns the webhook object or null if already registered. - * - * @throws ErrorException Throws an exception if there's an issue with the HTTP request or if webhook information is not found. - * @since 1.0.0 - */ - public function createWebhook(): ?object { - try { - - $webhookApiUrl = $this->config->get( 'api_url' ) . '/v1/notifications/webhooks'; - - $requestData = (object) array( - 'method' => 'get', - 'url' => $webhookApiUrl, - 'options' => array( 'headers' => $this->headers ), - ); - - $responseData = $this->sendHttpRequest( $requestData ); - - if ( isset( $responseData->webhooks ) && is_array( $responseData->webhooks ) ) { - - $registeredWebhookUrls = array_column( $responseData->webhooks, 'url' ); - $isWebhookUrlRegistered = in_array( $this->config->get( 'webhook_url' ), $registeredWebhookUrls ); - - return ! $isWebhookUrlRegistered ? $this->createNewWebhook() : null; - } - - throw new ErrorException( 'Webhook Information Not Found' ); - - } catch ( RequestException $error ) { - $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); - throw new ErrorException( esc_html( $errorMessage ) ); - } - } - - /** - * Creates a new webhook for the specified events. - * - * @return object Returns an object containing the webhook_id and webhook_url of the newly created webhook. - * - * @throws InvalidDataException Throws an exception if the webhook information is invalid or the creation fails. - * @throws ErrorException Throws an exception if the HTTP request fails. - * @since 1.0.0 - */ - private function createNewWebhook() { - try { - - $webhookApiUrl = $this->config->get( 'api_url' ) . '/v1/notifications/webhooks'; - - $body = (object) array( - 'url' => $this->config->get( 'webhook_url' ), - 'event_types' => array( (object) array( 'name' => 'PAYMENT.CAPTURE.REFUNDED' ) ), - ); - - $requestData = (object) array( - 'method' => 'post', - 'url' => $webhookApiUrl, - 'options' => array( - 'headers' => $this->headers, - 'body' => json_encode( $body ), - ), - ); - - $responseData = $this->sendHttpRequest( $requestData ); - - if ( $responseData->url === $this->config->get( 'webhook_url' ) && $responseData->id ) { - return (object) array( - 'webhook_id' => $responseData->id, - 'webhook_url' => $responseData->url, - ); - } - - throw new InvalidDataException( 'Invalid Webhook Information' ); - - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $errorMessage ) ); } @@ -438,9 +374,10 @@ private function createNewWebhook() { * * @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 = System::defaultOrderData( 'refund' ); + $returnData = Utils::defaultOrderData( 'refund' ); try { @@ -455,11 +392,11 @@ private function processRefund( $paymentData ): object { $returnData->refund_payload = json_encode( $paymentData ); } - $returnData->refund_status = static::getRefundStatus( $payloadStream->links, 'up' ); + $returnData->refund_status = Api::getRefundStatus( $payloadStream->links, 'up' ); return $returnData; - } catch ( RequestException $error ) { + } catch ( HttpRequestException $error ) { $errorMessage = Helper::handleErrorResponse( $error ) ?? $error->getMessage(); throw new ErrorException( esc_html( $errorMessage ) ); } diff --git a/ecommerce/PaymentGateways/Utils.php b/ecommerce/PaymentGateways/Utils.php new file mode 100644 index 0000000000..5cf0c361b0 --- /dev/null +++ b/ecommerce/PaymentGateways/Utils.php @@ -0,0 +1,238 @@ + 'payment', + 'id' => null, + 'payment_status' => 'unpaid', + 'payment_error_reason' => '', + 'transaction_id' => '', + 'payment_method' => '', + 'payment_payload' => '', + 'tax_amount' => '', + 'fees' => '', + 'earnings' => '', + ); + + } elseif ( $type === 'refund' ) { + $returnData = (object) array( + 'type' => 'refund', + 'id' => null, + 'refund_status' => '', + 'refund_id' => '', + 'payment_method' => '', + 'refund_amount' => '', + 'refund_error_reason' => '', + 'refund_payload' => '', + ); + } + + return $returnData; + } + + + /** + * Validates and sanitizes an email address. + * + * @param string $email The email address to be validated and sanitized. + * @return string The sanitized email address if valid. + * @throws InvalidDataException If the email address is invalid according to `FILTER_SANITIZE_EMAIL` + * or `FILTER_VALIDATE_EMAIL` filters. + * @since 1.0.0 + * @since 4.2.0 Moved from System. + */ + public static function validateAndSanitizeEmailAddress( string $email ) { + $sanitizeEmail = filter_var( $email, FILTER_SANITIZE_EMAIL ); + $validateEmail = filter_var( $email, FILTER_VALIDATE_EMAIL ); + + if ( ! $sanitizeEmail || ! $validateEmail ) { + throw new InvalidDataException( 'Invalid Email Address' ); + } + + return $email; + } + + /** + * Extracts the first and last name parts from a full name string. + * + * @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.2.0 Moved from System. + */ + public static function extractNameParts( $name ): array { + if ( empty( $name ) ) { + return array(); + } + + // Trim leading and trailing spaces, split the full name into an array, and remove empty elements + $nameParts = array_filter( explode( ' ', trim( $name ) ) ); + // Extract the last name. + $lastName = array_pop( $nameParts ); + // Extract the first name + $firstName = implode( ' ', $nameParts ); + + return array( $firstName, $lastName ); + } + + /** + * Splits the address into two parts if it exceeds a certain length. + * + * @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 + * @since 4.2.0 Moved from System. + */ + public static function splitAddress( $data, $maxLength ) { + if ( empty( $data->address1 ) ) { + return array(); + } + + $address_1 = mb_strimwidth( $data->address1, 0, $maxLength ); + $address_2 = ( strlen( $data->address1 ) > $maxLength ) ? mb_strimwidth( $data->address1, $maxLength, $maxLength ) : $data->address2; + + return array( $address_1, $address_2 ); + } + + /** + * Converts a major currency amount to its minor unit. + * + * @param float|string $amount The major currency amount to convert. + * @param string $currency The currency code to use for the conversion. + * + * @return int|float The minor currency amount as an integer, or 0.0 when the amount is empty. + * @since 1.0.0 + * @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 ) ) { + return Money::of( (float) $amount, $currency, null, RoundingMode::HALF_UP )->getMinorAmount()->toInt(); + } + + return 0.0; + } + + /** + * Converts a minor currency amount to its major unit equivalent. + * + * @param int|string $amount The minor currency amount to convert. + * @param string $currency The currency code to use for the conversion. + * + * @return float The major currency amount, or 0.0 when the amount is empty. + * @since 1.0.0 + * @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 ) ) { + return Money::ofMinor( $amount, $currency, null, RoundingMode::HALF_UP )->getAmount()->toFloat(); + } + + return 0.0; + } + + /** + * Determines if the total amount is zero or not. + * + * @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.2.0 Moved from System. + */ + public static function isTotalAmountZero( &$data ): bool { + $data->subtotal ??= 0; + $data->tax ??= 0; + $data->shipping_charge ??= 0; + $data->coupon_discount ??= 0; + + $totalAmount = ( $data->subtotal + $data->tax + $data->shipping_charge ) - $data->coupon_discount; + + return empty( filter_var( $totalAmount, FILTER_VALIDATE_FLOAT ) ) ? true : false; + } + + /** + * Updates and returns a webhook URL by encoding success and cancel URLs as parameters. + * + * @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.2.0 Moved from System. + */ + public static function updateWebhookUrl( $config ): string { + $encodedUrlData = base64_encode( + wp_json_encode( + array( + 'success_url' => $config->get( 'success_url' ), + 'cancel_url' => $config->get( 'cancel_url' ), + ) + ) + ); + + $webhookUrl = Uri::getInstance( $config->get( 'webhook_url' ) ); + $webhookUrl->setVar( 'encodedData', $encodedUrlData ); + $webhookUrl->setVar( 'payment_method', $config->get( 'name' ) ); + + return $webhookUrl->__toString(); + } + + /** + * Builds the Tutor metadata attached to payment gateway requests. + * + * @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 + * + * @return array The metadata as string key/value pairs. + */ + 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, + ); + } +} diff --git a/ecommerce/PaymentHandler.php b/ecommerce/PaymentHandler.php index 4a0df56130..d41d3b5a0e 100644 --- a/ecommerce/PaymentHandler.php +++ b/ecommerce/PaymentHandler.php @@ -10,7 +10,7 @@ namespace Tutor\Ecommerce; -use TUTOR\Input; +use Tutor\Core\Input; use WP_REST_Server; use WP_REST_Request; diff --git a/ecommerce/Settings.php b/ecommerce/Settings.php index 80ea922076..ae825e30b2 100644 --- a/ecommerce/Settings.php +++ b/ecommerce/Settings.php @@ -11,7 +11,7 @@ namespace Tutor\Ecommerce; use Tutor\Helpers\HttpHelper; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\PaymentGateways\Configs\PaypalConfig; use Tutor\Traits\JsonResponse; diff --git a/exceptions/DatabaseException.php b/exceptions/DatabaseException.php new file mode 100644 index 0000000000..45ab6f46ec --- /dev/null +++ b/exceptions/DatabaseException.php @@ -0,0 +1,21 @@ + $headers ) ); + if ( ! empty( $data ) ) { + $url = UrlHelper::add_query_params( $url, $data ); + } - $self = new self(); - $self->parse_response( $response ); + $args = array_merge( + array( + 'headers' => $headers, + 'method' => self::METHOD_GET, + ), + $args + ); - return $self; + 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. + * @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. + * @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() ) { - $response = wp_remote_post( - $url, + public static function post( $url, $data = array(), $headers = array(), $args = array() ) { + + $args = array_merge( array( 'body' => $data, 'headers' => $headers, - ) + 'method' => self::METHOD_POST, + ), + $args ); - $self = new self(); - $self->parse_response( $response ); - - return $self; + return self::send( $url, $args ); } /** @@ -154,10 +167,14 @@ public function get_body() { /** * Get body data as JSON * + * @since 4.2.0 param $associative added + * + * @param ?bool $associative True returns arrays, false returns objects, null follows the JSON_OBJECT_AS_ARRAY flag. + * * @return mixed */ - public function get_json() { - return json_decode( $this->body ); + public function get_json( ?bool $associative = null ) { + return json_decode( $this->body, $associative ); } /** @@ -195,4 +212,108 @@ public function has_error() { public function get_error_message() { return $this->wp_error->get_error_message(); } + + /** + * Sends an HTTP request and parses the response. + * + * @since 4.2.0 + * + * @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, + $args + ); + + $self = new self(); + $self->parse_response( $response ); + + return $self; + } + + /** + * Make HTTP PUT request. + * + * @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. + * @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, $data = array(), $headers = array(), $args = array() ) { + + $args = array_merge( + array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_PUT, + ), + $args + ); + + return self::send( $url, $args ); + } + + /** + * Make HTTP PATCH request. + * + * @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. + * @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, $data = array(), $headers = array(), $args = array() ) { + + $args = array_merge( + array( + 'body' => $data, + 'headers' => $headers, + 'method' => self::METHOD_PATCH, + ), + $args + ); + + return self::send( $url, $args ); + } + + /** + * Make HTTP DELETE request. + * + * @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. + * @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 ); + } } diff --git a/helpers/QueryHelper.php b/helpers/QueryHelper.php index 41b7e4d8f1..76ecd042d5 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.2.0 Use \Tutor\Helpers\DB instead. */ class QueryHelper { @@ -33,7 +34,7 @@ public static function insert( string $table, array $data, array $sanitize_mappi global $wpdb; $table = self::prepare_table_name( $table ); - $data = \TUTOR\Input::sanitize_array( $data, $sanitize_mapping ); + $data = \Tutor\Core\Input::sanitize_array( $data, $sanitize_mapping ); $insert = $wpdb->insert( $table, @@ -220,7 +221,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 @@ -279,6 +280,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 +299,7 @@ public static function make_clause( array $where ) { $value = self::prepare_value( $value ); } - return "{$field} {$upper_operator} {$value}"; + return "{$quoted_field} {$upper_operator} {$value}"; } /** @@ -331,6 +338,69 @@ 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 ); + } + + /** + * 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. + * + * 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 ) { + $quoted = array(); + + foreach ( explode( '.', (string) $identifier ) as $part ) { + $part = self::prepare_identifier( $part ); + + if ( '' === $part ) { + return ''; + } + + $quoted[] = '`' . $part . '`'; + } + + return implode( '.', $quoted ); + } + /** * Prepare where clause string * @@ -454,8 +524,16 @@ 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; + } + + $quoted_column = self::quote_sql_identifier( $column_name ); + $like_conditions[] = $wpdb->prepare( + //phpcs:ignore + sprintf( '%s LIKE %%s', $quoted_column ), + '%' . $wpdb->esc_like( $term ) . '%' + ); } $where_clause = implode( ' OR ', $like_conditions ); @@ -636,7 +714,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 ''; } @@ -660,7 +738,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 ''; } @@ -708,15 +786,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' ); @@ -805,15 +886,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 @@ -851,14 +933,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( @@ -885,24 +968,36 @@ 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 = self::quote_sql_identifier( $where_col ); if ( empty( $where_in ) || empty( $where_col ) ) { return false; } + + $in_array = is_array( $where_in ) ? $where_in : explode( ',', $where_in ); + $in_array = array_filter( array_map( static fn( $value ) => trim( $value, " \t\n\r\0\x0B'\"" ), $in_array ), static fn( $value ) => '' !== $value ); + $in_clause = self::prepare_in_clause( $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 } /** @@ -915,24 +1010,26 @@ 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 ) ) { continue; } + $safe_key = '`' . sanitize_key( $key ) . '`'; + if ( is_null( $value ) ) { - $set .= "$key = null"; + $set .= "{$safe_key} = null"; } else { - $value = esc_sql( sanitize_text_field( $value ) ); - $set .= is_numeric( $value ) ? "$key = $value" : "$key = '" . $value ."'"; + $prepared_value = self::prepare_value( $value ); + $set .= "{$safe_key} = {$prepared_value}"; } - - $set .= ","; + + $set .= ','; } return rtrim( $set, ',' ); } @@ -952,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 ); + $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 ); } @@ -989,7 +1086,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 } /** @@ -1005,9 +1102,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; + $sql = $wpdb->prepare( "SHOW COLUMNS FROM `{$table}` LIKE %s", $column ); //phpcs:ignore + return $wpdb->get_var( $sql ) === $column; //phpcs:ignore } /** @@ -1038,8 +1140,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, @@ -1065,11 +1167,11 @@ 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 ); - $has_records = is_array( $results ) && count( $results ); + $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; // Throw exception if error occurred. @@ -1099,18 +1201,18 @@ 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_column = '*' === $count_column ? '*' : self::quote_sql_identifier( $count_column ); - $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 ) { @@ -1134,22 +1236,24 @@ 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_column = '*' === $count_column ? '*' : self::quote_sql_identifier( $count_column ); - $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} "; - $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 ) { @@ -1184,33 +1288,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 ); + + $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; - + // 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; } @@ -1225,25 +1329,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; @@ -1259,7 +1369,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; } @@ -1287,7 +1397,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; } @@ -1299,9 +1409,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. */ @@ -1313,15 +1423,17 @@ 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.' ); } - // 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 ); @@ -1330,18 +1442,26 @@ 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 ); + //phpcs:disable $insert_sql = $wpdb->prepare( - "INSERT INTO `$table_name` (`" . implode( '`, `', $columns ) . "`) - VALUES (" . implode( ', ', $placeholders ) . ")", + "INSERT INTO `$table_name` (`" . implode( '`, `', $columns ) . '`) + VALUES (' . implode( ', ', $placeholders ) . ')', ...$values ); $result = $wpdb->query( $insert_sql ); + //phpcs:enable if ( false === $result ) { return new \WP_Error( 'insert_failed', 'Failed to insert duplicate row.' ); @@ -1374,19 +1494,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; - $result = $wpdb->get_results( "DESCRIBE {$table_name}", ARRAY_A ); + $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; } - } diff --git a/includes/bootstrap.php b/includes/bootstrap.php new file mode 100644 index 0000000000..f049a629c6 --- /dev/null +++ b/includes/bootstrap.php @@ -0,0 +1,39 @@ + + * @link https://themeum.com + */ + +defined( 'ABSPATH' ) || exit; + +/** + * Map old namespace to new namespace. + * + * @since 4.2.0 + * + * @return void + */ +$namespace_map = require __DIR__ . '/class-namespace-map.php'; +spl_autoload_register( + static function ( $class ) use ( $namespace_map ) { + if ( ! is_array( $namespace_map ) || ! isset( $namespace_map[ $class ] ) ) { + return; + } + + $new_class = $namespace_map[ $class ]; + if ( ! class_exists( $new_class ) ) { + return; + } + + class_alias( $new_class, $class ); + }, + true, + true +); + +unset( $namespace_map ); + +require_once __DIR__ . '/../vendor/autoload.php'; diff --git a/includes/class-namespace-map.php b/includes/class-namespace-map.php new file mode 100644 index 0000000000..fcc3420d7c --- /dev/null +++ b/includes/class-namespace-map.php @@ -0,0 +1,17 @@ + + * @link https://themeum.com + * @since 4.2.0 + */ + +defined( 'ABSPATH' ) || exit; + +return array( + 'TUTOR\\Input' => 'Tutor\\Core\\Input', + 'TUTOR\\Container' => 'Tutor\\Core\\Container', + 'TUTOR\\Singleton' => 'Tutor\\Core\\Singleton', +); diff --git a/includes/tutor-general-functions.php b/includes/tutor-general-functions.php index facc1d9dad..87ac673d3d 100644 --- a/includes/tutor-general-functions.php +++ b/includes/tutor-general-functions.php @@ -17,7 +17,7 @@ use Tutor\Ecommerce\OptionKeys; use Tutor\Ecommerce\Settings; use TUTOR\Icon; -use TUTOR\Input; +use Tutor\Core\Input; use Tutor\Models\CourseModel; use Tutor\Models\EnrollmentModel; diff --git a/models/CouponModel.php b/models/CouponModel.php index 7184a5ca55..12697fb2aa 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,96 @@ 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 ); + $count = 0; + + $user_id = get_current_user_id(); + + $results = DB::table( $this->coupon_usage_table ) + ->where( 'coupon_code', $coupon_code ) + ->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 ( 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; + } + } + + return $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 ) { + $count = 0; + + $results = DB::table( $this->coupon_usage_table ) + ->where( 'coupon_code', $coupon_code ) + ->where( 'user_id', $user_id ) + ->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 ( self::STATUS_CONSUMED === $status ) { + ++$count; + } + } else { + ++$count; + } + } + + return $count; } /** @@ -863,18 +924,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 +1052,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,35 +1101,37 @@ 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' ) ); } } - if ( $user_usage_limit > 0 ) { - $user_usage_count = $this->get_user_usage_count( $coupon->coupon_code, $user_id ); + 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; 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 +1261,162 @@ 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 $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 $reserve_duration = 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->table_name} 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( "+{$reserve_duration} 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, + ) + ); + } 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 * @@ -1298,4 +1516,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; + } } diff --git a/models/CourseModel.php b/models/CourseModel.php index f01d28814e..da5a68cee8 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -583,8 +583,8 @@ function ( $row ) { $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $content_id ) ); if ( is_array( $questions_ids ) && count( $questions_ids ) ) { $in_clause = QueryHelper::prepare_in_clause( $questions_ids ); - //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN({$in_clause}) " ) ); + //phpcs:ignore --safely prepared in clauses. + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE 1=%d AND belongs_question_id IN({$in_clause})", 1 ) ); } $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $content_id ) ); diff --git a/models/QuizModel.php b/models/QuizModel.php index 0167b3b822..50af46427d 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -1008,6 +1008,76 @@ public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = return $results; } + /** + * Get committed attempt answers keyed by question ID. + * + * @since 4.2.0 + * + * @param int $attempt_id Attempt ID. + * + * @return array + */ + public static function get_committed_answers_by_attempt( int $attempt_id ): array { + global $wpdb; + + if ( empty( $attempt_id ) ) { + return array(); + } + + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT question_id, given_answer, is_correct, achieved_mark + FROM {$wpdb->prefix}tutor_quiz_attempt_answers + WHERE quiz_attempt_id = %d", + $attempt_id + ), + OBJECT_K + ); + + return is_array( $results ) ? $results : array(); + } + + /** + * Get attempt answers aggregated statistics (earned marks, answered count). + * + * @since 4.2.0 + * + * @param int $attempt_id Attempt ID. + * + * @return object{total_earned_marks: float, total_answered_count: int} + */ + public static function get_attempt_answers_stats( int $attempt_id ): object { + $default = (object) array( + 'total_earned_marks' => 0.0, + 'total_answered_count' => 0, + ); + + if ( $attempt_id <= 0 ) { + return $default; + } + + global $wpdb; + + $stats = $wpdb->get_row( + $wpdb->prepare( + "SELECT COALESCE(SUM(achieved_mark), 0) AS total_earned_marks, + COUNT(*) AS total_answered_count + FROM {$wpdb->prefix}tutor_quiz_attempt_answers + WHERE quiz_attempt_id = %d", + $attempt_id + ) + ); + + if ( ! $stats ) { + return $default; + } + + return (object) array( + 'total_earned_marks' => (float) $stats->total_earned_marks, + 'total_answered_count' => (int) $stats->total_answered_count, + ); + } + /** * Check whether an attempt answer should be treated as skipped. * @@ -1797,7 +1867,10 @@ public static function prepare_attempt_result( $attempt ) { $answers = is_array( $answers ) ? $answers : array(); $has_pending = (bool) count( - array_filter( $answers, fn( $answer ) => null === $answer->is_correct ) + array_filter( + $answers, + fn( $answer ) => null === $answer->is_correct && ! self::is_attempt_answer_skipped( $answer ) + ) ); if ( $has_pending ) { diff --git a/models/WithdrawModel.php b/models/WithdrawModel.php index 088c980fa3..8c9768b00c 100644 --- a/models/WithdrawModel.php +++ b/models/WithdrawModel.php @@ -12,7 +12,7 @@ use Tutor\Helpers\QueryHelper; use Tutor\Helpers\UrlHelper; -use TUTOR\Input; +use Tutor\Core\Input; /** * WithdrawModel Class 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/readme.txt b/readme.txt index 9acb18ae2a..f3f154ed46 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: lms, course, elearning, education, learning management system Requires at least: 5.3 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 4.1.1 +Stable tag: 4.2.0 License: GPLv3 License URI: https://www.gnu.org/licenses/gpl-3.0.html @@ -338,6 +338,37 @@ Tutor LMS allows you to offer certificates to your students upon course completi == Changelog == += 4.2.0 - 27 Oct, 2026 + +New: Added REST API authentication with personalized access tokens. +New: Added endpoint-level authorization to ensure REST API operations follow user roles and capabilities. +Update: API key permissions for Read, Write, Delete, and All actions are now enforced across Tutor LMS REST API routes. +Update: Sensitive learning content routes now require valid enrollment, public access, or instructor/admin permissions. (Pro) +Update: Quiz answers are now verified server-side in Reveal Mode to prevent answer exposure. +Update: Optimized loading performance for the course list page in the WordPress admin area. +Update: Improved loading performance when exporting Analytics data. (Pro) +Update: Optimized the overall Analytics data export process. (Pro) +Fix: Fixed an issue where the Logout button was missing on mobile devices. +Fix: Fixed an issue where subscription expiration emails were triggered before every renewal email. (Pro) +Fix: Fixed a PHP warning appearing in the Courses section after disabling a subscription plan. (Pro) +Fix: Fixed skipped Essay questions preventing quiz completion and blocking instructors from manually grading or closing pending attempts. (Pro) +Fix: Fixed an issue preventing audio files from being submitted as assignment attachments. (Pro) +Fix: Fixed a race condition affecting coupon usage. +Fix: Fixed the "Use Points Instead of Grades" option not working correctly in the learner Gradebook within the Learning Area. (Pro) +Fix: Fixed a JavaScript error on the checkout page when a subscription was pending. (Pro) +Fix: Fixed unauthorized REST API access to course content, lessons, topics, announcements, quiz answers, quiz attempts, and user details. +Fix: Fixed the REST API returning a 200 OK response with empty course data for non-existent course IDs. +Fix: Fixed paid courses incorrectly returning a course price of "0" in REST API responses. + += 4.1.3 - 15 Oct, 2026 + +Fix: Patched a security vulnerability. + += 4.1.2 - 12 Oct, 2026 + +Fix: Resolved an issue where zoom & google meeting deleting was not working. (Pro) +Fix: Resolved an issue where student wasn't able to upload assignment attachment. (Pro) + = 4.1.1 - 06 Oct, 2026 Update: Optimized SQL queries for improved performance in Orders and Analytics. diff --git a/restapi/REST_Author.php b/restapi/REST_Author.php index 5385c741af..abcad7a1d3 100644 --- a/restapi/REST_Author.php +++ b/restapi/REST_Author.php @@ -10,11 +10,11 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; +use Tutor\Models\CourseModel; use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class REST_Author @@ -41,36 +41,64 @@ 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 ( ! is_a( $user_data, 'WP_User' ) ) { + return $this->response( + 'tutor_read_author', + __( 'Author not found', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } - if ( $author ) { - // Unset user pass & key. - unset( $author->user_pass ); - unset( $author->user_activation_key ); + $author = (object) array( + 'ID' => $user_data->ID, + 'display_name' => $user_data->display_name, + 'user_nicename' => $user_data->user_nicename, + 'courses' => self::get_published_instructor_course_ids( $this->user_id ), + ); - // Get author course ID. - $author->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' => 'success', - 'message' => __( 'Author details retrieved successfully', 'tutor' ), - 'data' => $author, - ); + return $this->response( + 'tutor_read_author', + __( 'Author details retrieved successfully', 'tutor' ), + $author, + HttpHelper::STATUS_OK + ); + } - 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; } - $response = array( - 'code' => 'invalid_id', - 'message' => __( 'Author not found', 'tutor' ), - 'data' => array(), - ); + foreach ( $courses as $course ) { + if ( is_object( $course ) && isset( $course->ID ) ) { + $ids[] = (int) $course->ID; + } + } - return self::send( $response ); + return $ids; } } diff --git a/restapi/REST_Course.php b/restapi/REST_Course.php index ca407a49ef..476799f82d 100644 --- a/restapi/REST_Course.php +++ b/restapi/REST_Course.php @@ -10,12 +10,16 @@ namespace TUTOR; -use WP_REST_Request; +use Tutor\Helpers\HttpHelper; +use Tutor\Helpers\QueryHelper; +use Tutor\Models\CourseModel; +use WP_Error; +use WP_Post; use WP_Query; +use WP_REST_Request; +use WP_REST_Response; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Rest_Course class @@ -69,13 +73,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 + * @return WP_REST_Response|WP_Error */ public function course( WP_REST_Request $request ) { - $order = sanitize_text_field( $request->get_param( 'order' ) ); - $orderby = sanitize_text_field( $request->get_param( 'orderby' ) ); + $order = QueryHelper::get_valid_sort_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'] ) ) { @@ -93,8 +100,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 ) ) { @@ -115,15 +122,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 ); @@ -132,14 +131,6 @@ public function course( WP_REST_Request $request ) { // if post found. if ( count( $query->posts ) > 0 ) { - // unset filter property. - array_map( - function ( $post ) { - unset( $post->filter ); - }, - $query->posts - ); - $data = array( 'posts' => array(), 'total_course' => $query->found_posts, @@ -147,54 +138,80 @@ function ( $post ) { ); foreach ( $query->posts as $post ) { - $category = wp_get_post_terms( $post->ID, $this->course_cat_tax ); + if ( ! $post instanceof WP_Post ) { + continue; + } - $tag = wp_get_post_terms( $post->ID, $this->course_tag_tax ); + $is_guest = ! RestAuth::is_rest_authenticated(); - $author = get_userdata( $post->post_author ); + if ( $is_guest ) { + $item = self::to_catalog_card_dto( $post ); + } else { + $item = (object) $post->to_array(); + unset( $item->filter, $item->post_password ); - if ( $author ) { - // Unset user pass & key. - unset( $author->data->user_pass ); - unset( $author->data->user_activation_key ); - } + $category = wp_get_post_terms( $post->ID, $this->course_cat_tax ); + + $tag = wp_get_post_terms( $post->ID, $this->course_tag_tax ); - is_a( $author, 'WP_User' ) ? $post->post_author = $author->data : new \stdClass(); + $author = get_userdata( $post->post_author ); - $thumbnail_size = apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ); - $post->thumbnail_url = get_the_post_thumbnail_url( $post->ID, $thumbnail_size ); + if ( $author ) { + $author_payload = (object) array( + 'ID' => $author->ID, + 'display_name' => $author->display_name, + 'user_nicename' => $author->user_nicename, + ); - $post->additional_info = $this->course_additional_info( $post->ID ); + 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; + } - $post->ratings = tutor_utils()->get_course_rating( $post->ID ); + $item->post_author = $author_payload; + } else { + $item->post_author = new \stdClass(); + } - $post->course_category = $category; + $thumbnail_size = apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ); + $item->thumbnail_url = get_the_post_thumbnail_url( $post->ID, $thumbnail_size ); - $post->course_tag = $tag; + $item->additional_info = $this->course_additional_info( + $post->ID, + ! RestAuth::can_reveal_course_internal_meta( $post->ID ) + ); - $post->price = get_post_meta( $post->ID, '_regular_price', true ); + $item->ratings = tutor_utils()->get_course_rating( $post->ID ); - $post = apply_filters( 'tutor_rest_course_single_post', $post ); + $item->course_category = $category; - array_push( $data['posts'], $post ); + $item->course_tag = $tag; + + $course_prices = tutor_utils()->get_raw_course_price( $post->ID ); + $item->price = $course_prices->regular_price; + $item->sale_price = $course_prices->sale_price; + } + + $item = apply_filters( 'tutor_rest_course_single_post', $item ); + + array_push( $data['posts'], $item ); } - $response = array( - 'code' => 'success', - 'message' => __( 'Course retrieved successfully', 'tutor' ), - 'data' => $data, + return $this->response( + 'tutor_read_course', + __( 'Course retrieved successfully', 'tutor' ), + $data, + HttpHelper::STATUS_OK ); - - return self::send( $response ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Course not found', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_course', + __( 'Course not found', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - - return self::send( $response ); } /** @@ -204,63 +221,68 @@ function ( $post ) { * * @param WP_REST_Request $request request params. * - * @return WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function course_detail( WP_REST_Request $request ) { - $post_id = $request->get_param( 'id' ); - - $detail = $this->course_additional_info( $post_id ); - if ( $detail ) { - $response = array( - 'code' => 'course_detail', - 'message' => __( 'Course detail retrieved successfully', 'tutor' ), - 'data' => $detail, + $post_id = absint( $request->get_param( 'id' ) ); + + if ( ! CourseModel::get_post_types( $post_id ) ) { + return $this->response( + 'tutor_read_course', + __( 'Detail not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - return self::send( $response ); } - $response = array( - 'code' => 'course_detail', - 'message' => __( 'Detail not found for given ID', 'tutor' ), - 'data' => array(), + + // Marketing viewers omit course_settings (enrolled / instructor / IA only). + $detail = $this->course_additional_info( + $post_id, + ! RestAuth::can_reveal_course_internal_meta( $post_id ) ); - return self::send( $response ); + return $this->response( + 'tutor_read_course', + __( 'Course detail retrieved successfully', 'tutor' ), + $detail, + HttpHelper::STATUS_OK + ); } /** * 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 ); } /** @@ -298,47 +320,245 @@ public function validate_terms( array $post ) { * * @param WP_REST_Request $request request params. * - * @return WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function course_contents( WP_REST_Request $request ) { - $course_id = $request->get_param( 'id' ); - $topics = tutor_utils()->get_topics( $course_id ); + $course_id = absint( $request->get_param( 'id' ) ); + + if ( ! CourseModel::get_post_types( $course_id ) ) { + return $this->response( + 'tutor_read_course', + __( 'Contents for this course with the given course id not found', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } + + $reveal_bodies = RestAuth::can_reveal_learning_payload( $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' => $reveal_bodies ? $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'], + $reveal_bodies + ? self::sanitize_course_content_item( $content_post ) + : self::sanitize_course_content_outline_item( $content_post ) + ); } } array_push( $data, $current_topic ); } - $response = array( - 'code' => 'success', - 'message' => __( 'Course contents retrieved successfully', 'tutor' ), - 'data' => $data, + return $this->response( + 'tutor_read_course', + __( 'Course contents retrieved successfully', 'tutor' ), + $data, + HttpHelper::STATUS_OK + ); + } + + return $this->response( + 'tutor_read_course', + __( 'Contents for this course with the given course id not found', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } + + /** + * 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, + ); + } + + /** + * 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 ); + $course_prices = tutor_utils()->get_raw_course_price( $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' => $course_prices->regular_price, + 'sale_price' => $course_prices->sale_price, + '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 self::send( $response ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Contents for this course with the given course id not found', 'tutor' ), - 'data' => array(), + return $out; + } + + /** + * 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 self::send( $response ); + return $args; } } diff --git a/restapi/REST_Course_Announcement.php b/restapi/REST_Course_Announcement.php index 337e3ef139..091beb97ae 100644 --- a/restapi/REST_Course_Announcement.php +++ b/restapi/REST_Course_Announcement.php @@ -10,11 +10,11 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; +use Tutor\Models\CourseModel; use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class REST_Course_Announcement @@ -51,29 +51,40 @@ class REST_Course_Announcement { */ public function course_announcement( WP_REST_Request $request ) { $this->post_parent = $request->get_param( 'id' ); + $course_id = absint( $this->post_parent ); - global $wpdb; + if ( ! CourseModel::get_post_types( $course_id ) ) { + return $this->response( + 'tutor_read_announcement', + __( 'Announcement not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } - $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 ) { - $response = array( - 'code' => 'success', - 'message' => __( 'Announcement retrieved successfully', 'tutor' ), - 'data' => $result, + return $this->response( + 'tutor_read_announcement', + __( 'Announcement retrieved successfully', 'tutor' ), + $result, + HttpHelper::STATUS_OK ); - - return self::send( $response ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Announcement not found for given ID', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_announcement', + __( 'Announcement not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - - return self::send( $response ); } } diff --git a/restapi/REST_Lesson.php b/restapi/REST_Lesson.php index 33be39b0b9..f9e9d6cfe0 100644 --- a/restapi/REST_Lesson.php +++ b/restapi/REST_Lesson.php @@ -10,12 +10,12 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; +use WP_Post; use WP_Query; use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class REST_Lesson @@ -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 @@ -56,18 +59,35 @@ public function topic_lesson( WP_REST_Request $request ) { $this->post_parent = $request->get_param( 'topic_id' ); if ( ! isset( $this->post_parent ) ) { - $response = array( - 'code' => 'not_found', - 'message' => __( 'topic_id is required', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_lesson', + __( 'topic_id is required', 'tutor' ), + array(), + HttpHelper::STATUS_BAD_REQUEST + ); + } + + $topic_id = absint( $this->post_parent ); + + if ( tutor()->topics_post_type !== get_post_type( $topic_id ) ) { + return $this->response( + 'tutor_read_lesson', + __( 'Lesson not found for the given topic ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - return self::send( $response ); } + $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,48 +97,63 @@ 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( - 'code' => 'success', - 'message' => __( 'Lesson retrieved successfully', 'tutor' ), - 'data' => $data, + return $this->response( + 'tutor_read_lesson', + __( 'Lesson retrieved successfully', 'tutor' ), + $data, + HttpHelper::STATUS_OK ); - - return self::send( $response ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Lesson not found for the given topic ID', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_lesson', + __( 'Lesson not found for the given topic ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); + } + + /** + * 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( WP_Post $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 self::send( $response ); + return $lesson; } } diff --git a/restapi/REST_Posts.php b/restapi/REST_Posts.php new file mode 100644 index 0000000000..9d925e2ca4 --- /dev/null +++ b/restapi/REST_Posts.php @@ -0,0 +1,98 @@ + + * @link https://themeum.com + * @since 4.2.0 + */ + +namespace TUTOR; + +use WP_Post; + +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, + ); + } + + /** + * 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. + * + * @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 0293dd8c8e..faec136a36 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -10,13 +10,12 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; use Tutor\Helpers\QueryHelper; use Tutor\Models\QuizModel; use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class REST_Quiz @@ -39,34 +38,6 @@ class REST_Quiz { */ private $post_parent; - /** - * Quiz questions table name - * - * @var string The table name for quiz questions. - */ - private $t_quiz_question = 'tutor_quiz_questions'; - - /** - * Quiz question answers table name - * - * @var string The table name for quiz question answers. - */ - private $t_quiz_ques_ans = 'tutor_quiz_question_answers'; - - /** - * Quiz question answer options table name - * - * @var string The table name for quiz attempts. - */ - private $t_quiz_attempt = 'tutor_quiz_attempts'; - - /** - * Quiz attempt answers table name - * - * @var string The table name for quiz attempt answers. - */ - private $t_quiz_attempt_ans = 'tutor_quiz_attempt_answers'; - /** * Obtain quiz detail for a single quiz. * @@ -79,38 +50,23 @@ class REST_Quiz { 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. + $quiz_id = absint( $request->get_param( 'id' ) ); + $quiz_post = get_post( $quiz_id ); - $wpdb->q_a_t = $wpdb->prefix . $this->t_quiz_ques_ans; // Question answer table. - - $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 ) ) { - $response = array( - 'code' => 'not_found', - 'message' => __( 'Quiz not found for given ID', 'tutor' ), - 'data' => array(), + if ( ! $quiz_post || $this->post_type !== $quiz_post->post_type || 'publish' !== $quiz_post->post_status ) { + return $this->response( + 'tutor_read_quiz', + __( 'Quiz not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - return self::send( $response ); } + $quiz = REST_Posts::to_public_post_dto( $quiz_post ); + $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, @@ -118,7 +74,7 @@ public function get_quiz( WP_REST_Request $request ) { question_description, question_type, question_mark, - question_settings FROM {$wpdb->q_t} + question_settings FROM {$wpdb->tutor_quiz_questions} WHERE quiz_id = %d ", $quiz->ID @@ -133,15 +89,18 @@ 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 = self::redact_answers_for_student( $questions ); + } + $quiz->quiz_questions = $questions; - $response = array( - 'code' => 'success', - 'message' => __( 'Quiz retrieved successfully', 'tutor' ), - 'data' => $quiz, + return $this->response( + 'tutor_read_quiz', + __( 'Quiz retrieved successfully', 'tutor' ), + $quiz, + HttpHelper::STATUS_OK ); - - return self::send( $response ); } /** @@ -155,48 +114,38 @@ 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' ); + $topic_id = absint( $this->post_parent ); + + if ( tutor()->topics_post_type !== get_post_type( $topic_id ) ) { + return $this->response( + 'tutor_read_quiz', + __( 'Quiz not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } - 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( - 'code' => 'success', - 'message' => __( 'Quiz retrieved successfully', 'tutor' ), - 'data' => $data, - ); } - return self::send( $response ); + + return $this->response( + 'tutor_read_quiz', + __( 'Quiz retrieved successfully', 'tutor' ), + $data, + HttpHelper::STATUS_OK + ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Quiz not found for given ID', 'tutor' ), - 'data' => $data, + return $this->response( + 'tutor_read_quiz', + __( 'Quiz not found for given ID', 'tutor' ), + $data, + HttpHelper::STATUS_NOT_FOUND ); - return self::send( $response ); } /** @@ -210,11 +159,18 @@ public function quiz_question_ans( WP_REST_Request $request ) { global $wpdb; $this->post_parent = $request->get_param( 'id' ); + $quiz_id = absint( $this->post_parent ); + + if ( $this->post_type !== get_post_type( $quiz_id ) ) { + return $this->response( + 'tutor_read_quiz', + __( 'Question not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } - $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 @@ -223,7 +179,7 @@ public function quiz_question_ans( WP_REST_Request $request ) { question_description, question_type, question_mark, - question_settings FROM {$wpdb->q_t} + question_settings FROM {$wpdb->tutor_quiz_questions} WHERE quiz_id = %d ", $this->post_parent @@ -237,41 +193,30 @@ 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 ); } - $response = array( - 'code' => 'success', - 'message' => __( 'Question retrieved successfully', 'tutor' ), - 'data' => $data, - ); + if ( ! RestAuth::can_reveal_quiz_answers( (int) $this->post_parent ) ) { + $data = self::redact_answers_for_student( $data ); + } - return self::send( $response ); + return $this->response( + 'tutor_read_quiz', + __( 'Question retrieved successfully', 'tutor' ), + $data, + HttpHelper::STATUS_OK + ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Question not found for given ID', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_quiz', + __( 'Question not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - - return self::send( $response ); } /** @@ -286,13 +231,22 @@ 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 = absint( $request->get_param( 'id' ) ); - $wpdb->quiz_attempt = $wpdb->prefix . $this->t_quiz_attempt; + if ( $this->post_type !== get_post_type( $quiz_id ) ) { + return $this->response( + 'tutor_read_quiz', + __( 'Quiz attempts not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } + // 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 + att.attempt_id, att.user_id, att.total_questions, att.total_answered_questions, @@ -303,8 +257,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->tutor_quiz_attempts} att WHERE att.quiz_id = %d ", $quiz_id @@ -312,52 +266,75 @@ 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 ); + $reveal_keys = RestAuth::can_reveal_quiz_answers( $quiz_id, $user_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 ); + // Attach answers for this attempt only. + $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(); } } - $response = array( - 'code' => 'success', - 'message' => __( 'Quiz attempts retrieved successfully', 'tutor' ), - 'data' => $attempts, - ); + $attempts = array_values( $attempts ); - return self::send( $response ); + return $this->response( + 'tutor_read_quiz', + __( 'Quiz attempts retrieved successfully', 'tutor' ), + $attempts, + HttpHelper::STATUS_OK + ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Quiz attempts not found for given ID', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_quiz', + __( 'Quiz attempts not found for given ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND ); - - return self::send( $response ); } /** - * Get quiz attempt answers. + * Get quiz attempt answers for a single attempt. * * @since 1.7.1 + * @since 4.2.0 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; - $wpdb->quiz_attempt_ans = $wpdb->prefix . $this->t_quiz_attempt_ans; - $wpdb->quiz_question = $wpdb->prefix . $this->t_quiz_question; - // get attempt answers. + $attempt_id = absint( $attempt_id ); + if ( ! $attempt_id ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom attempt/question tables; no WP API. $answers = $wpdb->get_results( $wpdb->prepare( "SELECT @@ -366,11 +343,11 @@ protected function get_quiz_attempt_ans( $quiz_id ) { att_ans.question_mark, 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 - WHERE att_ans.quiz_id = %d + att_ans.is_correct FROM {$wpdb->tutor_quiz_attempt_answers} as att_ans + JOIN {$wpdb->tutor_quiz_questions} q ON q.question_id = att_ans.question_id + WHERE att_ans.quiz_attempt_id = %d ", - $quiz_id + $attempt_id ) ); @@ -402,7 +379,6 @@ protected function get_quiz_attempt_ans( $quiz_id ) { */ protected function answer_titles_by_id( $id ) { global $wpdb; - $wpdb->t_quiz_ques_ans = $wpdb->prefix . $this->t_quiz_ques_ans; $ids = array_filter( array_map( 'absint', (array) $id ) ); if ( empty( $ids ) ) { @@ -411,14 +387,58 @@ 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->tutor_quiz_question_answers} + WHERE + answer_id IN ({$array})" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- IDs prepared via QueryHelper::prepare_in_clause. ); return $results; } + + /** + * 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. + * + * @return array + */ + 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 ) ) { + continue; + } + unset( $answer->is_correct, $answer->answer_two_gap_match ); + } + } + + return $questions; + } } diff --git a/restapi/REST_Rating.php b/restapi/REST_Rating.php index ead4aadb6b..a5c000c0d0 100644 --- a/restapi/REST_Rating.php +++ b/restapi/REST_Rating.php @@ -10,11 +10,11 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; +use Tutor\Models\CourseModel; use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class REST_Rating @@ -52,7 +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; private courses for enrolled viewers; expose my_rating. * * @param WP_REST_Request $request The REST request object. * @@ -64,27 +70,115 @@ 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 ( ! $this->can_read_course_rating( $this->post_id ) ) { + return $this->response( + 'tutor_read_rating', + __( 'Course not found', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } + + $is_guest = ! RestAuth::is_rest_authenticated(); + $current_user_id = $is_guest ? 0 : (int) tutor_utils()->get_user_id(); - $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' ), + $current_user_id + ); - if ( ! empty( $ratings ) ) { - $response = array( - 'code' => 'success', - 'message' => __( 'Course rating retrieved successfully', 'tutor' ), - 'data' => $ratings, + 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(), ); - return self::send( $response ); + 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, + ); + } + } + + return $this->response( + 'tutor_read_rating', + __( 'Course rating retrieved successfully', 'tutor' ), + $payload, + HttpHelper::STATUS_OK + ); + } + + $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; + if ( ! RestAuth::can_view_user_private_fields( $user_id ) ) { + unset( $review->comment_author_email ); + } + } + + if ( $ratings->my_rating && ! RestAuth::can_view_user_private_fields( $current_user_id ) ) { + unset( $ratings->my_rating->comment_author_email ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Rating not found for given ID', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_rating', + __( 'Course rating retrieved successfully', 'tutor' ), + $ratings, + 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 self::send( $response ); + return false; } } diff --git a/restapi/REST_Response.php b/restapi/REST_Response.php index 63bc60383f..ff5dcc32d4 100644 --- a/restapi/REST_Response.php +++ b/restapi/REST_Response.php @@ -10,26 +10,136 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; +use WP_Error; use WP_REST_Response; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; +/** + * Centralized REST response helpers (aligned with Tutor Pro RestResponse). + * + * @since 1.7.1 + */ trait REST_Response { /** - * Send response + * Build a Tutor REST API response (same envelope rules as Tutor Pro). * - * @since 1.7.1 - * @since 2.7.0 renamed filter to tutor_rest_api_response like as pro API response. + * @since 4.2.0 * - * @param array $response The response data. + * @param string $code Operation / error code. + * @param string $message Response message. + * @param mixed $data Response data or error details. + * @param int $status_code HTTP status code. * - * @return WP_REST_Response + * @return WP_REST_Response|WP_Error */ - public static function send( array $response ) { - $response = new WP_REST_Response( $response ); + public function response( $code, string $message, $data = '', int $status_code = 200 ) { + if ( $status_code >= HttpHelper::STATUS_BAD_REQUEST ) { + $error_data = array( 'status' => $status_code ); + if ( '' !== $data && null !== $data ) { + $error_data['details'] = $data; + } + + $response = new WP_Error( $code, $message, $error_data ); + } else { + $response = new WP_REST_Response( + array( + 'code' => $code, + 'message' => $message, + 'data' => $data, + ), + $status_code + ); + } + + /** + * Filter Tutor REST API responses (including WP_Error). + * + * @since 2.7.0 + * + * @param WP_REST_Response|WP_Error $response Response object. + */ return rest_ensure_response( apply_filters( 'tutor_rest_api_response', $response ) ); } + + /** + * Send validation error response. + * + * @since 4.2.0 + * + * @param array $errors Validation errors. + * @param string $code Operation code. + * @param string|null $message Optional message. + * + * @return WP_REST_Response|WP_Error + */ + public function validation_error_response( $errors, $code, $message = null ) { + $default_message = _n( 'Invalid input', 'Invalid inputs', count( $errors ), 'tutor' ); + + return $this->response( + $code, + null !== $message ? $message : $default_message, + $errors, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + /** + * Log an unexpected failure and return a safe API response. + * + * @since 4.2.0 + * + * @param string $code Operation code. + * @param string $message Generic translated message for the client. + * @param \Throwable|\WP_Error|string $error Error to log server-side only. + * + * @return WP_REST_Response|WP_Error + */ + public function exception_error_response( $code, $message, $error ) { + self::log_api_error( $error ); + + return $this->response( + $code, + $message, + '', + HttpHelper::STATUS_INTERNAL_SERVER_ERROR + ); + } + + /** + * Log API error details server-side without exposing them to clients. + * + * @since 4.2.0 + * + * @param \Throwable|\WP_Error|string $error Error to log. + * + * @return void + */ + protected static function log_api_error( $error ) { + if ( $error instanceof \Throwable ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional server-side logging of API failures. + error_log( + sprintf( + 'Tutor REST API error: %s in %s at line %d', + $error->getMessage(), + $error->getFile(), + $error->getLine() + ) + ); + return; + } + + if ( is_wp_error( $error ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional server-side logging of API failures. + error_log( 'Tutor REST API error: ' . $error->get_error_message() ); + return; + } + + if ( is_string( $error ) && '' !== $error ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional server-side logging of API failures. + error_log( 'Tutor REST API error: ' . $error ); + } + } } diff --git a/restapi/REST_Topic.php b/restapi/REST_Topic.php index 368f98350b..02412c58d4 100644 --- a/restapi/REST_Topic.php +++ b/restapi/REST_Topic.php @@ -10,11 +10,11 @@ namespace TUTOR; +use Tutor\Helpers\HttpHelper; +use Tutor\Models\CourseModel; use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Class REST_Topic @@ -53,35 +53,56 @@ public function course_topic( WP_REST_Request $request ) { $this->post_parent = $request->get_param( 'course_id' ); if ( ! isset( $this->post_parent ) ) { - $response = array( - 'code' => 'get_topic', - 'message' => __( 'course_id is required', 'tutor' ), - 'data' => array(), + return $this->response( + 'tutor_read_topic', + __( 'course_id is required', 'tutor' ), + array(), + HttpHelper::STATUS_BAD_REQUEST ); - return self::send( $response ); } - global $wpdb; + $course_id = absint( $this->post_parent ); - $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 ) - ); + if ( ! CourseModel::get_post_types( $course_id ) ) { + return $this->response( + 'tutor_read_topic', + __( 'Topic not found for given course ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); + } - if ( count( $result ) > 0 ) { - $response = array( - 'code' => 'get_topic', - 'message' => __( 'Topic retrieved successfully', 'tutor' ), - 'data' => $result, + $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 ); + } - return self::send( $response ); + if ( count( $result ) > 0 ) { + return $this->response( + 'tutor_read_topic', + __( 'Topic retrieved successfully', 'tutor' ), + $result, + HttpHelper::STATUS_OK + ); } - $response = array( - 'code' => 'not_found', - 'message' => __( 'Topic not found for given course ID', 'tutor' ), - 'data' => array(), - ); - return self::send( $response ); + return $this->response( + 'tutor_read_topic', + __( 'Topic not found for given course ID', 'tutor' ), + array(), + HttpHelper::STATUS_NOT_FOUND + ); } } diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index f36d41bec8..d6bb8f56ca 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -12,11 +12,15 @@ namespace TUTOR; -use Tutor\Helpers\QueryHelper; +use Tutor\Core\DB; +use Tutor\Core\Input; +use Tutor\Helpers\HttpHelper; +use Tutor\Models\CourseModel; +use Tutor\Models\EnrollmentModel; +use Tutor\Models\QuizModel; +use WP_REST_Request; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; /** * Rest API authentication @@ -67,69 +71,393 @@ class RestAuth { */ const KEYS_USER_META_KEY = 'tutor-api-key-secret'; + /** + * Usermeta: refresh token hashes (keyed per user; lookup via signed JWT `sub`). + * + * @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'; + + /** + * TTL value meaning no expiration (unlimited). + * + * @since 4.2.0 + * + * @var int + */ + const TTL_UNLIMITED = 0; + + /** + * Minimum token lifetime in days when not unlimited. + * + * @since 4.2.0 + * + * @var int + */ + const MIN_TTL_DAYS = 1; + + /** + * Maximum token lifetime in days. + * + * @since 4.2.0 + * + * @var int + */ + const MAX_TTL_DAYS = 365; + + /** + * Default access JWT lifetime in seconds (1 day). + * + * @var int + */ + const ACCESS_TTL = DAY_IN_SECONDS; + + /** + * Default refresh token lifetime in seconds (30 days). + * + * @var int + */ + const REFRESH_TTL = DAY_IN_SECONDS * 30; + + /** + * Option key for access JWT lifetime (seconds). + * + * @since 4.2.0 + * + * @var string + */ + const OPTION_ACCESS_TTL = 'rest_api_access_token_ttl'; + + /** + * Option key for refresh token lifetime (seconds). + * + * @since 4.2.0 + * + * @var string + */ + const OPTION_REFRESH_TTL = 'rest_api_refresh_token_ttl'; + + /** + * Option flag: plaintext API secrets have been migrated to HMAC hashes. + * + * @since 4.2.0 + * + * @var string + */ + const OPTION_API_SECRETS_HASHED = 'tutor_api_secrets_hashed'; + + /** + * 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. * * @since 2.2.1 + * @since 4.2.0 $register_hooks param added to reuse this class without registering hooks. + * + * @param bool $register_hooks Whether to register hooks. Default true. * * @return void */ - 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' ); + public function __construct( $register_hooks = true ) { + if ( ! $register_hooks ) { + return; + } + + add_action( 'wp_ajax_tutor_generate_api_keys', array( $this, 'generate_api_keys' ) ); + add_action( 'wp_ajax_tutor_update_api_permission', array( $this, 'update_api_permission' ) ); + add_action( 'wp_ajax_tutor_revoke_api_keys', array( $this, 'revoke_api_keys' ) ); + add_action( 'wp_ajax_tutor_save_rest_api_token_settings', array( $this, 'save_token_settings' ) ); + add_action( 'admin_init', array( $this, 'maybe_migrate_plaintext_api_secrets' ) ); 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( $this, 'enforce_actor_identity' ), 20, 3 ); } /** - * API auth. + * One-time migration: hash any leftover plaintext API secrets in usermeta. * - * @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.2.0 * - * @param int|false $user_id user id. + * @return void + */ + public function maybe_migrate_plaintext_api_secrets() { + if ( get_option( static::OPTION_API_SECRETS_HASHED ) ) { + return; + } + + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $results = DB::table( 'usermeta' ) + ->where( 'meta_key', static::KEYS_USER_META_KEY ) + ->order_by( 'umeta_id', 'DESC' ) + ->get(); + + if ( is_array( $results ) ) { + foreach ( $results as $row ) { + $meta = json_decode( $row->meta_value ); + if ( is_object( $meta ) && ! empty( $meta->secret ) ) { + self::persist_normalized_api_key_meta( (int) $row->umeta_id, $meta ); + } + } + } + + update_option( static::OPTION_API_SECRETS_HASHED, 1, false ); + } + + /** + * Configured access JWT lifetime in seconds (0 = unlimited). * - * @return int|false + * @since 4.2.0 + * + * @return int */ - public function api_auth( $user_id ) { - // Don't authenticate twice. - if ( ! empty( $user_id ) || ! self::is_tutor_api_request() ) { - return $user_id; + public static function get_access_ttl() { + return self::normalize_ttl_seconds( + (int) tutor_utils()->get_option( static::OPTION_ACCESS_TTL, static::ACCESS_TTL ), + static::ACCESS_TTL + ); + } + + /** + * Configured refresh token lifetime in seconds (0 = unlimited). + * + * @since 4.2.0 + * + * @return int + */ + public static function get_refresh_ttl() { + return self::normalize_ttl_seconds( + (int) tutor_utils()->get_option( static::OPTION_REFRESH_TTL, static::REFRESH_TTL ), + static::REFRESH_TTL + ); + } + + /** + * Convert stored TTL seconds to whole days for admin UI. + * + * @since 4.2.0 + * + * @param int $seconds TTL in seconds (0 = unlimited). + * + * @return int Days (0 = unlimited). + */ + public static function ttl_seconds_to_days( $seconds ) { + $seconds = (int) $seconds; + if ( $seconds <= static::TTL_UNLIMITED ) { + return static::TTL_UNLIMITED; } - if ( ! wp_is_application_passwords_available() ) { - return $user_id; + $days = (int) round( $seconds / DAY_IN_SECONDS ); + return max( static::MIN_TTL_DAYS, min( static::MAX_TTL_DAYS, $days ) ); + } + + /** + * Convert admin UI days to stored TTL seconds. + * + * @since 4.2.0 + * + * @param int $days Days (0 = unlimited). + * + * @return int Seconds (0 = unlimited). + */ + public static function ttl_days_to_seconds( $days ) { + $days = (int) $days; + if ( $days <= static::TTL_UNLIMITED ) { + return static::TTL_UNLIMITED; } - if ( ! isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { - return $user_id; + return $days * DAY_IN_SECONDS; + } + + /** + * Whether a day-based TTL is allowed (0 or 1–365). + * + * @since 4.2.0 + * + * @param int $days Days value from admin UI. + * + * @return bool + */ + public static function is_valid_ttl_days( $days ) { + $days = (int) $days; + if ( static::TTL_UNLIMITED === $days ) { + return true; } - $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 ); + return $days >= static::MIN_TTL_DAYS && $days <= static::MAX_TTL_DAYS; + } - if ( ! $record ) { - return $user_id; + /** + * Normalize a stored TTL in seconds (0 = unlimited, otherwise clamp to max). + * + * @since 4.2.0 + * + * @param int $ttl Stored seconds. + * @param int $default_seconds Fallback when value is invalid. + * + * @return int + */ + private static function normalize_ttl_seconds( $ttl, $default_seconds ) { + $ttl = (int) $ttl; + if ( static::TTL_UNLIMITED === $ttl ) { + return static::TTL_UNLIMITED; + } + + if ( $ttl < 0 ) { + return (int) $default_seconds; + } + + $max_seconds = static::MAX_TTL_DAYS * DAY_IN_SECONDS; + return min( $ttl, $max_seconds ); + } + + /** + * Whether a refresh-token exp timestamp is still valid (0 = unlimited). + * + * @since 4.2.0 + * + * @param int $exp Expiration unix timestamp, or 0 for unlimited. + * @param int $now Current unix timestamp. + * + * @return bool + */ + private static function is_refresh_exp_valid( $exp, $now ) { + $exp = (int) $exp; + if ( static::TTL_UNLIMITED === $exp ) { + return true; + } + + return $exp > (int) $now; + } + + /** + * Save Rest API token lifetime settings from Tools. + * + * Admin UI posts lifetimes in days; values are stored as seconds. + * 0 means unlimited / no expiration. + * + * @since 4.2.0 + * + * @return void + */ + public static function save_token_settings() { + tutor_utils()->check_nonce(); + tutor_utils()->check_current_user_capability(); + + $default_access_days = self::ttl_seconds_to_days( static::ACCESS_TTL ); + $default_refresh_days = self::ttl_seconds_to_days( static::REFRESH_TTL ); + $access_days = Input::post( static::OPTION_ACCESS_TTL, $default_access_days, Input::TYPE_INT ); + $refresh_days = Input::post( static::OPTION_REFRESH_TTL, $default_refresh_days, Input::TYPE_INT ); + + if ( ! self::is_valid_ttl_days( $access_days ) ) { + tutor_utils()->json_response( + sprintf( + /* translators: 1: min days, 2: max days */ + __( 'Access token lifetime must be 0 (unlimited) or between %1$d and %2$d days.', 'tutor' ), + static::MIN_TTL_DAYS, + static::MAX_TTL_DAYS + ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + if ( ! self::is_valid_ttl_days( $refresh_days ) ) { + tutor_utils()->json_response( + sprintf( + /* translators: 1: min days, 2: max days */ + __( 'Refresh token lifetime must be 0 (unlimited) or between %1$d and %2$d days.', 'tutor' ), + static::MIN_TTL_DAYS, + static::MAX_TTL_DAYS + ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + if ( static::TTL_UNLIMITED === $access_days && static::TTL_UNLIMITED !== $refresh_days ) { + tutor_utils()->json_response( + __( 'When the access token has no expiration, the refresh token must also have no expiration.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); } - $meta = json_decode( $record->meta_value ); - if ( ! is_object( $meta ) || ! isset( $meta->permission ) || self::ALL !== $meta->permission ) { + if ( + static::TTL_UNLIMITED !== $access_days + && static::TTL_UNLIMITED !== $refresh_days + && $refresh_days <= $access_days + ) { + tutor_utils()->json_response( + __( 'Refresh token lifetime must be greater than access token lifetime.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + tutor_utils()->update_option( static::OPTION_ACCESS_TTL, self::ttl_days_to_seconds( $access_days ) ); + tutor_utils()->update_option( static::OPTION_REFRESH_TTL, self::ttl_days_to_seconds( $refresh_days ) ); + + tutor_utils()->json_response( __( 'Token settings saved successfully', 'tutor' ) ); + } + + /** + * 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.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 ( ! static::is_tutor_api_request() ) { return $user_id; } - return (int) $record->user_id; + $token = self::get_access_token_from_request(); + if ( ! $token ) { + return false; + } + + $jwt_user_id = self::verify_access_token( $token ); + if ( ! $jwt_user_id ) { + return false; + } + + 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 +473,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,21 +484,92 @@ public static function is_tutor_api_request() { return false; } + /** + * Whether request is a Tutor auth login/refresh/logout route. + * + * @since 4.2.0 + * + * @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.2.0 + * + * @return bool + */ + public static function is_login_route() { + return self::auth_path_matches( 'login' ); + } + + /** + * Whether request is the auth refresh route. + * + * @since 4.2.0 + * + * @return bool + */ + public static function is_refresh_route() { + return self::auth_path_matches( 'refresh' ); + } + + /** + * Whether request is the auth logout route. + * + * @since 4.2.0 + * + * @return bool + */ + public static function is_logout_route() { + return self::auth_path_matches( 'logout' ); + } + + /** + * Whether the request path matches a Tutor auth endpoint segment. + * + * @since 4.2.0 + * + * @param string $segment login|refresh|logout. + * + * @return bool + */ + private static function auth_path_matches( $segment ) { + 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/' . $segment; + + return false !== strpos( $path, $base . '/' ) || false !== strpos( $path, $base ); + } + /** * Generate api keys * + * Stores only a HMAC of the secret. The plaintext secret is returned once + * in the AJAX response for the admin to copy; it is never persisted. + * * @since 2.2.1 + * @since 4.2.0 Validate permissions; store secret_hash instead of plaintext. * - * @return void send wp_json response + * @return void 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() ); - } + tutor_utils()->check_nonce(); + tutor_utils()->check_current_user_capability(); $api_key = 'key_' . bin2hex( random_bytes( 16 ) ); $api_secret = 'secret_' . bin2hex( random_bytes( 32 ) ); @@ -178,73 +577,133 @@ public static function generate_api_keys() { $permission = Input::post( 'permission' ); $description = Input::post( 'description', '', Input::TYPE_TEXTAREA ); + if ( ! self::is_allowed_api_permission( $permission ) ) { + tutor_utils()->json_response( + __( 'Invalid permission.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + $info = wp_json_encode( array( 'key' => $api_key, - 'secret' => $api_secret, + 'secret_hash' => self::hash_api_secret( $api_secret ), 'permission' => $permission, 'description' => $description, ) ); - // 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 ); - wp_send_json_success( $response ); - } else { - wp_send_json_error( tutor_utils()->error_message( '0' ) ); + if ( ! $add ) { + tutor_utils()->json_response( + tutor_utils()->error_message( '0' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); } + // Plaintext secret is returned once for the modal; it is not stored in usermeta. + tutor_utils()->json_response( + __( 'API key & secret generated. Copy the secret now and store it securely — it will not be shown again.', 'tutor' ), + array( + 'html' => static::prepare_response( $add, $api_key, $permission, $description ), + 'key' => $api_key, + 'secret' => $api_secret, + ) + ); } - /** * Update api permission * * @since 2.5.0 + * @since 4.2.0 Validate permissions and meta_key; never re-expose secrets. * - * @return void send wp_json response + * @return void JSON response. */ 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() ); - } + tutor_utils()->check_nonce(); + tutor_utils()->check_current_user_capability(); $meta_id = Input::post( 'meta_id', 0, Input::TYPE_INT ); $permission = Input::post( 'permission' ); $description = Input::post( 'description', '', Input::TYPE_TEXTAREA ); - $info = QueryHelper::get_row( $wpdb->usermeta, array( 'umeta_id' => $meta_id ), 'umeta_id' ); + if ( ! $meta_id ) { + tutor_utils()->json_response( + __( 'Invalid meta id', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + if ( ! self::is_allowed_api_permission( $permission ) ) { + tutor_utils()->json_response( + __( 'Invalid permission.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + $info = DB::table( 'usermeta' ) + ->where( 'umeta_id', $meta_id ) + ->first(); + if ( ! is_object( $info ) || static::KEYS_USER_META_KEY !== $info->meta_key ) { + tutor_utils()->json_response( + __( 'Invalid API key record.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + return; + } + $meta_value = json_decode( $info->meta_value ); + if ( ! is_object( $meta_value ) || empty( $meta_value->key ) ) { + tutor_utils()->json_response( + __( 'Invalid API key record.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + $meta_value = self::normalize_api_key_meta( $meta_value ); $meta_value->permission = $permission; $meta_value->description = $description; - // Update user meta. try { - QueryHelper::update( - $wpdb->usermeta, - array( 'meta_value' => json_encode( $meta_value ) ), - array( 'umeta_id' => $meta_id ) - ); + DB::table( 'usermeta' ) + ->where( 'umeta_id', $meta_id ) + ->where( 'meta_key', static::KEYS_USER_META_KEY ) + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Primary-key update by umeta_id; writing the column, not querying by it. + ->update( array( 'meta_value' => wp_json_encode( $meta_value ) ) ); - $response = self::prepare_response( $meta_id, $meta_value->key, $meta_value->secret, $permission, $description ); - wp_send_json_success( $response ); + $response = static::prepare_response( $meta_id, $meta_value->key, $permission, $description ); + tutor_utils()->json_response( + __( 'API key permission updated successfully', 'tutor' ), + $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() + ) + ); + tutor_utils()->json_response( + __( 'API permission update failed, please try again.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); } } @@ -252,40 +711,61 @@ public static function update_api_permission() { * Revoke api keys * * @since 2.2.1 + * @since 4.2.0 Require meta_key = KEYS_USER_META_KEY before delete. * - * @return void send wp_json response + * @return void 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() ); - } + tutor_utils()->check_nonce(); + tutor_utils()->check_current_user_capability(); $meta_id = Input::post( 'meta_id', 0, Input::TYPE_INT ); if ( ! $meta_id ) { - wp_send_json_error( __( 'Invalid meta id', 'tutor' ) ); + tutor_utils()->json_response( + __( 'Invalid meta id', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); + } + + $info = DB::table( 'usermeta' ) + ->where( 'umeta_id', $meta_id ) + ->first(); + if ( ! is_object( $info ) || static::KEYS_USER_META_KEY !== $info->meta_key ) { + tutor_utils()->json_response( + __( 'Invalid API key record.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); } - // Delete api keys. - global $wpdb; - $delete = QueryHelper::delete( $wpdb->usermeta, array( 'umeta_id' => $meta_id ) ); + $deleted = DB::table( 'usermeta' ) + ->where( 'umeta_id', $meta_id ) + ->where( 'meta_key', static::KEYS_USER_META_KEY ) + ->delete(); - if ( $delete ) { - wp_send_json_success( __( 'API keys permanently revoked', 'tutor' ) ); - } else { - wp_send_json_error( __( 'API keys revoke failed, please try again.', 'tutor' ) ); + if ( ! $deleted ) { + tutor_utils()->json_response( + __( 'API keys revoke failed, please try again.', 'tutor' ), + null, + HttpHelper::STATUS_BAD_REQUEST + ); } + + tutor_utils()->json_response( __( 'API keys permanently revoked', 'tutor' ) ); } /** * Check if api key & secret is valid * + * Looks up by public key (indexed LIKE on meta_value), then verifies the + * secret against a stored HMAC with hash_equals. Legacy plaintext secrets + * are accepted once and upgraded to secret_hash. + * * @since 2.2.1 * @since 2.7.1 $return_result param added. + * @since 4.2.0 Keyed lookup; hashed secrets; constant-time compare. * * @param string $api_key api key. * @param string $api_secret api secret. @@ -294,76 +774,1502 @@ public static function revoke_api_keys() { * @return bool|object */ public static function validate_api_key_secret( $api_key, $api_secret, $return_result = false ) { - global $wpdb; - $table = $wpdb->usermeta; + $api_key = is_string( $api_key ) ? $api_key : ''; + $api_secret = is_string( $api_secret ) ? $api_secret : ''; - $valid = false; + if ( '' === $api_key || '' === $api_secret ) { + return false; + } + + // Generated keys are key_ + 32 hex chars; reject other shapes early. + if ( ! preg_match( '/^key_[a-f0-9]{32}$/', $api_key ) ) { + return false; + } + + // where_like() auto-wraps with % and applies esc_like. + $results = DB::table( 'usermeta' ) + ->where( 'meta_key', static::KEYS_USER_META_KEY ) + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Narrowed by meta_key first; public key substring match (no secret scan). + ->where_like( 'meta_value', '"key":"' . $api_key . '"' ) + ->order_by( 'umeta_id', 'DESC' ) + ->limit( 5 ) + ->get(); + + if ( ! is_array( $results ) || ! count( $results ) ) { + return false; + } + + foreach ( $results as $result ) { + $obj = json_decode( $result->meta_value ); + if ( ! is_object( $obj ) || empty( $obj->key ) ) { + continue; + } + + if ( ! hash_equals( (string) $obj->key, $api_key ) ) { + continue; + } + + if ( ! self::verify_api_secret( $api_secret, $obj ) ) { + continue; + } + + self::maybe_upgrade_api_secret_meta( (int) $result->umeta_id, $obj, $api_secret ); + + if ( $return_result ) { + return $result; + } + + return true; + } + + return false; + } + + /** + * HMAC-SHA256 of an API secret using the site auth salt as pepper. + * + * @since 4.2.0 + * + * @param string $api_secret Plaintext API secret. + * + * @return string Hex digest. + */ + private static function hash_api_secret( $api_secret ) { + return hash_hmac( 'sha256', (string) $api_secret, wp_salt( 'auth' ) ); + } + + /** + * Verify a plaintext secret against stored meta (hash or legacy plaintext). + * + * @since 4.2.0 + * + * @param string $api_secret Plaintext secret from the request. + * @param object $meta Decoded API key meta object. + * + * @return bool + */ + private static function verify_api_secret( $api_secret, $meta ) { + if ( ! empty( $meta->secret_hash ) && is_string( $meta->secret_hash ) ) { + return hash_equals( (string) $meta->secret_hash, self::hash_api_secret( $api_secret ) ); + } + + // Legacy rows stored the secret in plaintext. + if ( ! empty( $meta->secret ) && is_string( $meta->secret ) ) { + return hash_equals( (string) $meta->secret, (string) $api_secret ); + } + + return false; + } + + /** + * Upgrade a legacy plaintext secret to secret_hash after a successful match. + * + * @since 4.2.0 + * + * @param int $umeta_id Usermeta row id. + * @param object $meta Decoded meta object. + * @param string $api_secret Verified plaintext secret. + * + * @return void + */ + private static function maybe_upgrade_api_secret_meta( $umeta_id, $meta, $api_secret ) { + $umeta_id = absint( $umeta_id ); + if ( ! $umeta_id || ! is_object( $meta ) ) { + return; + } + + $needs_upgrade = empty( $meta->secret_hash ) || ! empty( $meta->secret ); + if ( ! $needs_upgrade ) { + return; + } + + $meta->secret_hash = self::hash_api_secret( $api_secret ); + unset( $meta->secret ); + + DB::table( 'usermeta' ) + ->where( 'umeta_id', $umeta_id ) + ->where( 'meta_key', static::KEYS_USER_META_KEY ) + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Primary-key update by umeta_id; writing the column, not querying by it. + ->update( array( 'meta_value' => wp_json_encode( $meta ) ) ); + } + + /** + * Normalize API key meta: hash any leftover plaintext secret, drop secret field. + * + * Safe to call from admin list/update when the plaintext is still in the row. + * + * @since 4.2.0 + * + * @param object $meta Decoded meta object. + * + * @return object + */ + public static function normalize_api_key_meta( $meta ) { + if ( ! is_object( $meta ) ) { + return $meta; + } + + if ( ! empty( $meta->secret ) && is_string( $meta->secret ) ) { + if ( empty( $meta->secret_hash ) ) { + $meta->secret_hash = self::hash_api_secret( $meta->secret ); + } + unset( $meta->secret ); + } + + return $meta; + } + + /** + * Persist normalization for a usermeta API key row (admin list migration). + * + * @since 4.2.0 + * + * @param int $umeta_id Usermeta id. + * @param object $meta Decoded meta (may contain plaintext secret). + * + * @return object Normalized meta. + */ + public static function persist_normalized_api_key_meta( $umeta_id, $meta ) { + $umeta_id = absint( $umeta_id ); + $before = is_object( $meta ) ? wp_json_encode( $meta ) : ''; + $meta = self::normalize_api_key_meta( $meta ); + $after = is_object( $meta ) ? wp_json_encode( $meta ) : ''; + + if ( ! $umeta_id || $before === $after ) { + return $meta; + } + + DB::table( 'usermeta' ) + ->where( 'umeta_id', $umeta_id ) + ->where( 'meta_key', static::KEYS_USER_META_KEY ) + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Primary-key update by umeta_id; writing the column, not querying by it. + ->update( array( 'meta_value' => $after ) ); + + return $meta; + } + + /** + * 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.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. + */ + private static function get_api_key_permission() { + if ( static::is_login_route() ) { + return self::get_permission_from_api_credentials(); + } + + $kid = self::get_access_token_kid(); + if ( ! $kid ) { + return ''; + } + + return self::get_permission_by_kid( $kid ); + } + + /** + * Permission from API key/secret headers. + * + * @since 4.2.0 + * + * @return string + */ + private static function get_permission_from_api_credentials() { + $credentials = self::get_api_credentials_from_request(); + if ( ! $credentials ) { + return ''; + } + + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! is_object( $record ) ) { + return ''; + } + + return self::permission_from_key_meta( $record->meta_value ); + } + + /** + * Permission for an API key usermeta row id (kid). + * + * @since 4.2.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 ''; + } + + $record = DB::table( 'usermeta' ) + ->where( 'umeta_id', $kid ) + ->first(); + if ( ! is_object( $record ) || static::KEYS_USER_META_KEY !== $record->meta_key ) { + return ''; + } + + return self::permission_from_key_meta( $record->meta_value ); + } + + /** + * Extract permission string from API key meta JSON. + * + * @since 4.2.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 ''; + } + + return (string) $meta->permission; + } + + /** + * API key id (umeta_id) from the verified access token on this request. + * + * @since 4.2.0 + * + * @return int + */ + private static function get_access_token_kid() { + $token = self::get_access_token_from_request(); + if ( ! $token ) { + return 0; + } + + if ( null !== self::$verified_token_claims && isset( self::$verified_token_claims['kid'] ) ) { + return absint( self::$verified_token_claims['kid'] ); + } + + if ( ! self::verify_access_token( $token ) ) { + return 0; + } + + return isset( self::$verified_token_claims['kid'] ) ? absint( self::$verified_token_claims['kid'] ) : 0; + } + + /** + * Whether the API key grants Read (or higher). + * + * @since 4.2.0 + * + * @return bool + */ + public static function process_read_request() { + $permission = self::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Whether the API key grants Write (or higher). + * + * @since 4.2.0 + * + * @return bool + */ + public static function process_write_request() { + $permission = self::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 All). + * + * Matches pre-4.0.10 Pro route allowlists: Delete and All only. + * Write / Read/Write do not authorize DELETE. + * + * @since 4.2.0 + * + * @return bool + */ + public static function process_delete_request() { + $permission = self::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + return in_array( $permission, array( static::DELETE, static::ALL ), true ); + } + + /** + * 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.2.0 Key permission by method; login accepts any valid key; other routes use JWT kid. + * + * @return boolean + */ + public static function process_api_request() { + // Login exchanges credentials for a JWT; any valid key permission may authenticate. + if ( static::is_login_route() ) { + return '' !== self::get_api_key_permission(); + } + + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : HttpHelper::METHOD_GET; + + if ( HttpHelper::METHOD_DELETE === $method ) { + return static::process_delete_request(); + } + + if ( in_array( + $method, + array( + HttpHelper::METHOD_POST, + HttpHelper::METHOD_PUT, + HttpHelper::METHOD_PATCH, + ), + true + ) ) { + return static::process_write_request(); + } + + return static::process_read_request(); + } + + /** + * WP_Error when the access token is missing or invalid. + * + * @since 4.2.0 + * + * @param bool $require_token When false, an empty token is allowed (returns null); + * a present but invalid token still returns an error. + * + * @return \WP_Error|null Null when the token is acceptable for the given mode. + */ + public static function get_access_token_error( $require_token = true ) { + $token = self::get_access_token_from_request(); + + if ( '' === $token ) { + if ( ! $require_token ) { + return null; + } + + return new \WP_Error( + 'rest_missing_access_token', + __( 'Access token is required.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! self::verify_access_token( $token ) ) { + return new \WP_Error( + 'rest_invalid_access_token', + __( 'Invalid access token.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return null; + } + + /** + * Authorize JWT + API key method scope for permission_callback use. + * + * Distinguishes missing/invalid access tokens from insufficient key permissions. + * + * @since 4.2.0 + * + * @param string $scope One of: read, write, delete, api. + * + * @return true|\WP_Error + */ + public static function authorize_authenticated_request( $scope = 'api' ) { + $token_error = self::get_access_token_error(); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + switch ( $scope ) { + case 'read': + $allowed = static::process_authenticated_read_request(); + break; + case 'write': + $allowed = static::process_authenticated_write_request(); + break; + case 'delete': + $allowed = static::process_authenticated_delete_request(); + break; + default: + $allowed = static::process_authenticated_api_request(); + break; + } + + if ( ! $allowed ) { + return new \WP_Error( + 'rest_forbidden', + __( 'API key does not have permission for this request.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Whether the request has a JWT-authenticated WordPress user. + * + * @since 4.2.0 + * + * @return bool + */ + 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.2.0 + * + * @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.2.0 + * + * @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.2.0 + * + * @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 when the route does not declare a specific read/write/delete check. + * + * @since 4.2.0 + * + * @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.2.0 + * + * @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.2.0 + * + * @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 + * + * @since 4.2.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() ) + ); + } + } + + $course_id = absint( $request->get_param( 'course_id' ) ); + + 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() ) + ); + } + } + + // 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 ( ! $allowed ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this user.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } + + 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). + * + * 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: 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 + * + * @param WP_REST_Request $request request. + * + * @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' ) ); + } + + return static::is_public_profile_layout_open( $user_id ); + } + + /** + * Permission: course marketing detail (overview for purchase/enroll). + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool|\WP_Error + */ + public static function permission_course_detail( WP_REST_Request $request ) { + $course_id = absint( $request->get_param( 'id' ) ); + $valid = self::require_valid_course( $course_id ); + if ( true !== $valid ) { + return $valid; + } + + $token_error = self::get_access_token_error( false ); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + if ( 'publish' !== get_post_status( $course_id ) ) { + $token_error = self::get_access_token_error(); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + if ( ! static::process_api_request() ) { + return false; + } + + $user_id = tutor_utils()->get_user_id(); + return $user_id && tutor_utils()->can_user_edit_course( $user_id, $course_id ); + } + + if ( Course_List::is_public( $course_id ) ) { + return true; + } + + // Authenticated users may open the marketing/overview page without enrollment. + if ( static::process_api_request() ) { + return true; + } + + return ! tutor_utils()->get_option( 'student_must_login_to_view_course' ); + } + + /** + * 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 = 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; + } + + $token_error = self::get_access_token_error( false ); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + if ( static::process_api_request() ) { + return static::can_view_course_content( $course_id ); + } + + return 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|\WP_Error + */ + public static function permission_authenticated_course_content( WP_REST_Request $request ) { + $token_error = self::get_access_token_error(); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + if ( ! static::process_api_request() ) { + return false; + } + + $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 ); + } + + /** + * Permission: topics by course_id — JWT or public course. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool|\WP_Error + */ + public static function permission_topics( WP_REST_Request $request ) { + $course_id = absint( $request->get_param( 'course_id' ) ); - $results = QueryHelper::get_all( - $table, - array( 'meta_key' => self::KEYS_USER_META_KEY ), //phpcs:ignore - 'umeta_id' - ); + $token_error = self::get_access_token_error( false ); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } - 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 ) { - $valid = true; - if ( $return_result ) { - return $result; - } - break; - } + $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_List::is_public( $course_id ); + } + + /** + * Permission: lessons or quizzes listed by topic_id — JWT or public course. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool|\WP_Error + */ + public static function permission_by_topic( WP_REST_Request $request ) { + $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_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|\WP_Error + */ + public static function permission_authenticated_by_topic( WP_REST_Request $request ) { + $token_error = self::get_access_token_error(); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + if ( ! static::process_api_request() ) { + return false; + } + + $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 ); + } + + /** + * Permission: quiz by quiz id (always requires access token). + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return bool|\WP_Error + */ + public static function permission_quiz( WP_REST_Request $request ) { + $token_error = self::get_access_token_error(); + if ( is_wp_error( $token_error ) ) { + return $token_error; + } + + if ( ! static::process_api_request() ) { + return false; + } + + $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 ); + } + + /** + * 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. + * @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 || ! CourseModel::get_post_types( $course_id ) ) { + 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; + } + + if ( ! $user_id ) { + return false; } - return $valid; + if ( EnrollmentModel::is_enrolled( $course_id, $user_id ) ) { + return true; + } + + return (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); } /** - * Process api request + * Whether internal course_settings meta may be included in REST payloads. * - * @since 2.2.1 + * Unlike can_view_course_content(), public courses alone do not qualify — + * settings are not marketing fields on single-course.php. * - * @return boolean + * @since 4.2.0 + * + * @param int $course_id course id. + * @param int $user_id user id. + * + * @return bool */ - public static function process_api_request() { - $headers = apache_request_headers(); + public static function can_reveal_course_internal_meta( $course_id, $user_id = 0 ) { + if ( ! static::is_rest_authenticated() ) { + return false; + } + + $course_id = absint( $course_id ); + $user_id = tutor_utils()->get_user_id( $user_id ); + if ( ! $course_id || ! $user_id || ! CourseModel::get_post_types( $course_id ) ) { + return false; + } - if ( isset( $headers['Authorization'] ) ) { - $authorization_header = $headers['Authorization']; + if ( EnrollmentModel::is_enrolled( $course_id, $user_id ) ) { + return true; + } - if ( strpos( $authorization_header, 'Basic' ) !== false ) { - $base_64_credentials = str_replace( 'Basic ', '', $authorization_header ); - $credentials = base64_decode( $base_64_credentials ); //phpcs:ignore + if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) { + return true; + } - list($api_key, $api_secret) = explode( ':', $credentials ); + return (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + } - if ( self::validate_api_key_secret( $api_key, $api_secret ) ) { - return true; - } + /** + * 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 = tutor_utils()->get_user_id( $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 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 = tutor_utils()->get_user_id( $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. + * @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 = tutor_utils()->get_user_id( $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; + } + + 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 ); + } + + /** + * Whether viewer may see private user fields (email, login, registered). + * + * @since 4.2.0 + * + * @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 = tutor_utils()->get_user_id( $viewer_id ); + + if ( ! $viewer_id ) { + return false; + } + + // Privileged users may see private fields, including guest reviews (user_id 0). + if ( user_can( $viewer_id, 'list_users' ) ) { + return true; + } + + if ( ! $target_user_id ) { + return false; + } + + if ( $target_user_id === $viewer_id ) { + 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; } } - // Key and secret are invalid or not provided. return false; } /** - * Prepare html response + * Shared REST response helper for static auth handlers. + * + * Avoids constructing RestAuth (which registers hooks) and keeps a single + * instance `response()` API like Tutor Pro. + * + * @since 4.2.0 + * + * @return object Object using REST_Response. + */ + private static function responder() { + static $responder = null; + + if ( null === $responder ) { + $responder = new class() { + use REST_Response; + }; + } + + return $responder; + } + + /** + * Login — issue access + refresh tokens. + * + * Requires a valid API key/secret with any permission (permission_callback). The key id + * is bound into issued tokens so later requests need only the Bearer token. + * + * @since 4.2.0 + * @since 4.2.0 Bind API key id (kid) into access and refresh tokens. + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function login( WP_REST_Request $request ) { + $responder = self::responder(); + $credentials = self::get_api_credentials_from_request(); + if ( ! $credentials ) { + return $responder->response( + 'tutor_auth_login', + __( 'API key and secret are required.', 'tutor' ), + '', + HttpHelper::STATUS_FORBIDDEN + ); + } + + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! is_object( $record ) ) { + return $responder->response( + 'tutor_auth_login', + __( 'Invalid API key or secret.', 'tutor' ), + '', + HttpHelper::STATUS_FORBIDDEN + ); + } + + $kid = absint( $record->umeta_id ); + if ( ! $kid || '' === self::permission_from_key_meta( $record->meta_value ) ) { + return $responder->response( + 'tutor_auth_login', + __( 'Invalid API key or secret.', 'tutor' ), + '', + HttpHelper::STATUS_FORBIDDEN + ); + } + + $username = sanitize_text_field( (string) $request->get_param( 'username' ) ); + $password = (string) $request->get_param( 'password' ); + + if ( '' === $username || '' === $password ) { + return $responder->response( + 'tutor_auth_login', + __( 'Invalid username or password.', 'tutor' ), + '', + HttpHelper::STATUS_UNAUTHORIZED + ); + } + + if ( is_email( $username ) ) { + $user_by_email = get_user_by( 'email', $username ); + if ( $user_by_email ) { + $username = $user_by_email->user_login; + } + } + + $user = wp_authenticate( $username, $password ); + if ( is_wp_error( $user ) ) { + return $responder->response( + 'tutor_auth_login', + __( 'Invalid username or password.', 'tutor' ), + '', + HttpHelper::STATUS_UNAUTHORIZED + ); + } + + return $responder->response( + 'tutor_auth_login', + __( 'Login successful.', 'tutor' ), + self::build_token_response( (int) $user->ID, $kid ), + HttpHelper::STATUS_OK + ); + } + + /** + * Refresh access token (rotates refresh token). + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function refresh( WP_REST_Request $request ) { + $responder = self::responder(); + $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); + if ( '' === $refresh ) { + return $responder->response( + 'tutor_auth_refresh', + __( 'Invalid refresh token.', 'tutor' ), + '', + HttpHelper::STATUS_UNAUTHORIZED + ); + } + + $session = self::consume_refresh_token( $refresh ); + if ( ! $session ) { + return $responder->response( + 'tutor_auth_refresh', + __( 'Invalid refresh token.', 'tutor' ), + '', + HttpHelper::STATUS_UNAUTHORIZED + ); + } + + if ( '' === self::get_permission_by_kid( $session['kid'] ) ) { + return $responder->response( + 'tutor_auth_refresh', + __( 'API key has been revoked.', 'tutor' ), + '', + HttpHelper::STATUS_FORBIDDEN + ); + } + + return $responder->response( + 'tutor_auth_refresh', + __( 'Token refreshed successfully.', 'tutor' ), + self::build_token_response( $session['user_id'], $session['kid'] ), + HttpHelper::STATUS_OK + ); + } + + /** + * Logout — revoke refresh token(s); when all=true, also bump token_version. + * + * @since 4.2.0 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function logout( WP_REST_Request $request ) { + $responder = self::responder(); + $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); + $all = (bool) $request->get_param( 'all' ); + + if ( $all ) { + $token = self::get_access_token_from_request(); + $user_id = $token ? self::verify_access_token( $token ) : 0; + if ( ! $user_id && $refresh ) { + $user_id = self::find_user_id_by_refresh_token( $refresh ); + } + if ( $user_id ) { + static::invalidate_user_tokens( $user_id ); + } + } elseif ( $refresh ) { + self::delete_refresh_token( $refresh ); + } + + return $responder->response( + 'tutor_auth_logout', + __( 'Logged out successfully.', 'tutor' ), + array( + 'success' => true, + ), + HttpHelper::STATUS_OK + ); + } + + /** + * Invalidate tokens when password changes on profile update. + * + * @since 4.2.0 + * + * @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.2.0 + * + * @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 ); + self::delete_all_refresh_tokens( $user_id ); + } + + /** + * Prepare html response for the API keys table row. + * + * Secrets are never included — they are shown only in the generate modal once. * * @since 2.2.1 + * @since 4.2.0 Drop secret column; credentials revealed only in the generate modal. * * @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 = '' ) { + public static function prepare_response( $meta_id, $key, $permission, $description = '' ) { $user_id = get_current_user_id(); ob_start(); ?> @@ -374,16 +2280,8 @@ public static function prepare_response( $meta_id, $key, $secret, $permission, $ - - - - - - - - - - + + @@ -430,10 +2328,554 @@ public static function prepare_response( $meta_id, $key, $secret, $permission, $ public static function available_permissions(): array { $permissions = array( array( - 'value' => self::READ, + 'value' => static::READ, 'label' => __( 'Read', 'tutor' ), ), ); 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. + * + * @param int $user_id user id. + * @param int $kid API key usermeta id. + * + * @return array + */ + private static function build_token_response( $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( + '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. + * @param int $kid API key usermeta id. + * + * @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 ); + $access_ttl = self::get_access_ttl(); + + $claims = array( + 'sub' => (int) $user_id, + 'iat' => $now, + 'iss' => 'tutor', + 'tv' => $tv, + 'kid' => $kid, + ); + + if ( $access_ttl > static::TTL_UNLIMITED ) { + $claims['exp'] = $now + $access_ttl; + } + + return array( + 'token' => self::encode_jwt( $claims ), + 'expires_in' => $access_ttl, + ); + } + + /** + * Verify access JWT. Returns user id or 0. + * + * @param string $jwt token. + * + * @return int + */ + private static function verify_access_token( $jwt ) { + self::$verified_token_claims = null; + + $payload = self::decode_jwt( $jwt ); + if ( ! $payload ) { + return 0; + } + + // Access tokens must not carry the refresh typ claim. + if ( ! empty( $payload->typ ) && 'refresh' === $payload->typ ) { + return 0; + } + + $kid = isset( $payload->kid ) ? absint( $payload->kid ) : 0; + if ( ! $kid || '' === self::get_permission_by_kid( $kid ) ) { + 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; + } + + self::$verified_token_claims = array( + 'user_id' => $user_id, + 'kid' => $kid, + ); + + 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; + } + + /** + * 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). + * + * @param string $data raw. + * + * @return string + */ + private static function base64url_encode( $data ) { + if ( function_exists( 'sodium_bin2base64' ) ) { + return sodium_bin2base64( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- JWT Base64URL fallback when ext-sodium is unavailable. + return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); + } + + /** + * Base64 URL decode (JWT-safe, no padding). + * + * @param string $data encoded. + * + * @return string + */ + private static function base64url_decode( $data ) { + if ( function_exists( 'sodium_base642bin' ) ) { + try { + return sodium_base642bin( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); + } catch ( \Throwable $e ) { + return ''; + } + } + + $remainder = strlen( $data ) % 4; + if ( $remainder ) { + $data .= str_repeat( '=', 4 - $remainder ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- JWT Base64URL fallback when ext-sodium is unavailable. + $decoded = base64_decode( strtr( $data, '-_', '+/' ), true ); + return false === $decoded ? '' : $decoded; + } + + /** + * 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['tutor-user-token'] ) ) { + return trim( $headers['tutor-user-token'] ); + } + + if ( ! empty( $headers['authorization'] ) && 0 === stripos( $headers['authorization'], 'Bearer ' ) ) { + return trim( substr( $headers['authorization'], 7 ) ); + } + + return ''; + } + + /** + * 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 null; + } + + return array( + 'key' => sanitize_text_field( $headers['tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), + ); + } + + /** + * 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'] = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + } + + return $headers; + } + + /** + * 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. + * + * @return string + */ + private static function issue_refresh_token( $user_id, $kid ) { + $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( + $list, + function ( $row ) use ( $now ) { + if ( ! is_array( $row ) || empty( $row['hash'] ) || ! isset( $row['exp'] ) ) { + return false; + } + + return self::is_refresh_exp_valid( $row['exp'], $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' => $exp, + 'kid' => $kid, + ); + + update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); + + return $token; + } + + /** + * Validate and remove refresh token; return user id + kid. + * + * @param string $token refresh token. + * + * @return array{user_id:int,kid:int}|null + */ + private static function consume_refresh_token( $token ) { + $session = self::find_refresh_session( $token ); + if ( ! $session ) { + return null; + } + + self::delete_refresh_token( $token, $session['user_id'] ); + return $session; + } + + /** + * Find user id owning a refresh token. + * + * @param string $token refresh token. + * + * @return int + */ + private static function find_user_id_by_refresh_token( $token ) { + $session = self::find_refresh_session( $token ); + return $session ? $session['user_id'] : 0; + } + + /** + * 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. + * + * @return array{user_id:int,kid:int}|null + */ + private static function find_refresh_session( $token ) { + $payload = self::verify_refresh_jwt( $token ); + if ( ! $payload ) { + return null; + } + + $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; + } + + $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 for a known (or JWT-derived) user. + * + * @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 = 0 ) { + $user_id = absint( $user_id ); + if ( ! $user_id ) { + $payload = self::verify_refresh_jwt( $token ); + if ( ! $payload ) { + return; + } + $user_id = (int) $payload->sub; + } + + $hash = hash( 'sha256', $token ); + $list = self::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 ) ); + } + + /** + * 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(); + } } diff --git a/templates/archive-course-init.php b/templates/archive-course-init.php index e619c8cad8..97ed31f5de 100644 --- a/templates/archive-course-init.php +++ b/templates/archive-course-init.php @@ -9,7 +9,7 @@ * @since 1.0.0 */ -use TUTOR\Input; +use Tutor\Core\Input; ! isset( $course_filter ) ? $course_filter = false : 0; ! isset( $supported_filters ) ? $supported_filters = tutor_utils()->get_option( 'supported_course_filters', array() ) : 0; diff --git a/templates/archive-course.php b/templates/archive-course.php index e89ef20d67..92c303ae1c 100644 --- a/templates/archive-course.php +++ b/templates/archive-course.php @@ -9,7 +9,7 @@ * @since 1.5.8 */ -use TUTOR\Input; +use Tutor\Core\Input; tutor_utils()->tutor_custom_header(); diff --git a/templates/course-filter/course-archive-filter-bar.php b/templates/course-filter/course-archive-filter-bar.php index 6751cc8ce4..cbb09c3062 100644 --- a/templates/course-filter/course-archive-filter-bar.php +++ b/templates/course-filter/course-archive-filter-bar.php @@ -13,7 +13,7 @@ return; } -$sort_by = \TUTOR\Input::get( 'course_order', '' ); +$sort_by = \Tutor\Core\Input::get( 'course_order', '' ); ?>