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', '' );
?>