Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,268 changes: 1,268 additions & 0 deletions .agent/skills/core-development-skill/SKILL.md

Large diffs are not rendered by default.

666 changes: 666 additions & 0 deletions .agent/skills/wpcs-security-skill/SKILL.md

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions GDPR/DB/LegalConsents.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,20 @@ public static function get_schema() {
$charset_collate = $wpdb->get_charset_collate();

return "CREATE TABLE {$table_name} (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id BIGINT UNSIGNED AUTO_INCREMENT,
consent_title VARCHAR(255) NOT NULL,
display_on TEXT NOT NULL, -- comma separate value for multiple scopes
display_on TEXT NOT NULL,
consent_message TEXT NOT NULL,
consent_map JSON, -- JSON map [terms_conditions => 1]
consent_map JSON,
version VARCHAR(20) NOT NULL,
consent_method VARCHAR(255) NOT NULL,
is_active TINYINT(1) DEFAULT 1,
settings JSON,
created_at_gmt DATETIME NOT NULL,
updated_at_gmt DATETIME,
INDEX (consent_title),
INDEX (is_active)
PRIMARY KEY (id),
KEY consent_title (consent_title),
KEY is_active (is_active)
) {$charset_collate};";
}
}
7 changes: 4 additions & 3 deletions GDPR/DB/Logs.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ public static function get_schema() {
$charset_collate = $wpdb->get_charset_collate();

return "CREATE TABLE {$table_name} (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id BIGINT UNSIGNED AUTO_INCREMENT,
legal_consent_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(50), -- created, updated, deleted
action VARCHAR(50),
old_data JSON NULL,
new_data JSON NULL,
created_at_gmt DATETIME NOT NULL
created_at_gmt DATETIME NOT NULL,
PRIMARY KEY (id)
) {$charset_collate};";
}
}
11 changes: 6 additions & 5 deletions GDPR/DB/UserConsents.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public static function get_schema() {
$charset_collate = $wpdb->get_charset_collate();

return "CREATE TABLE {$table_name} (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id BIGINT UNSIGNED AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL,
user_email VARCHAR(190) NOT NULL,
consent_title VARCHAR(100) NOT NULL,
Expand All @@ -53,11 +53,12 @@ public static function get_schema() {
consent_method VARCHAR(255) NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
source VARCHAR(50), -- consent page info
source VARCHAR(50),
created_at_gmt DATETIME NOT NULL,
INDEX (user_id),
INDEX (consent_title),
INDEX (created_at_gmt)
PRIMARY KEY (id),
KEY user_id (user_id),
KEY consent_title (consent_title),
KEY created_at_gmt (created_at_gmt)
) {$charset_collate};";
}
}
100 changes: 94 additions & 6 deletions classes/Course.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

namespace TUTOR;

use Exception;
use InvalidArgumentException;

defined( 'ABSPATH' ) || exit;

use Tutor\Components\Button;
Expand Down Expand Up @@ -328,7 +331,7 @@
global $wp_query;
$course_coming_soon_enabled = (int) get_post_meta( $content->ID, '_tutor_course_enable_coming_soon', true );
$is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $content->ID, true );
if ( ! CourseModel::get_post_types( $content ) || current_user_can( 'administrator' ) || $is_instructor || $course_coming_soon_enabled ) {

Check failure on line 334 in classes/Course.php

View workflow job for this annotation

GitHub Actions / WPCS

Capabilities should be used instead of roles. Found "administrator" in function call to current_user_can()
return $content;
}

Expand Down Expand Up @@ -659,7 +662,7 @@
} else {
$errors['pricing'] = __( 'Invalid product', 'tutor' );
}
} else {

Check failure on line 665 in classes/Course.php

View workflow job for this annotation

GitHub Actions / WPCS

If control structure block found as the only statement within an "else" block. Use elseif instead.
/**
* If user does not select WC product
* Then automatic WC product will be create name with course title.
Expand Down Expand Up @@ -796,7 +799,7 @@
update_post_meta( $post_id, self::COURSE_PRICE_TYPE_META, $params['pricing']['type'] );
}
} catch ( \Throwable $th ) {
throw new \Exception( $th->getMessage() );

Check failure on line 802 in classes/Course.php

View workflow job for this annotation

GitHub Actions / WPCS

All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found '$th'.
}
}

Expand Down Expand Up @@ -1753,6 +1756,7 @@
*
* @since 1.0.0
* @since 3.9.9 Check if user can manage course before updating order.
* @since 4.0.8 Course content validation added.
*
* @return void
*/
Expand All @@ -1766,8 +1770,9 @@
wp_send_json_error( __( 'Sorting order is required', 'tutor' ) );
}

$topic_id = (int) isset( $sorting_order[0], $sorting_order[0]['topic_id'] ) ? $sorting_order[0]['topic_id'] : 0;
$course_id = wp_get_post_parent_id( $topic_id );
$topic_id = (int) isset( $sorting_order[0], $sorting_order[0]['topic_id'] ) ? $sorting_order[0]['topic_id'] : 0;
$course_id = wp_get_post_parent_id( $topic_id );
$content_parent = Input::post( 'content_parent', array(), Input::TYPE_ARRAY );

if ( ! $topic_id || ! $course_id ) {
$this->response_bad_request( tutor_utils()->error_message( 'invalid_req' ) );
Expand All @@ -1777,10 +1782,15 @@
$this->json_response( tutor_utils()->error_message(), null, HttpHelper::STATUS_UNAUTHORIZED );
}

if ( Input::has( 'content_parent' ) ) {
$content_parent = Input::post( 'content_parent', array(), Input::TYPE_ARRAY );
$topic_id = tutor_utils()->array_get( 'parent_topic_id', $content_parent );
$content_id = tutor_utils()->array_get( 'content_id', $content_parent );
try {
$this->validate_course_content_order( $course_id, $topic_id, $sorting_order, $content_parent );
} catch ( \Throwable $th ) {
$this->response_bad_request( $th->getMessage() );
}

if ( ! empty( $content_parent ) ) {
$topic_id = tutor_utils()->array_get( 'parent_topic_id', $content_parent );
$content_id = tutor_utils()->array_get( 'content_id', $content_parent );

// Update the parent topic id of the content.
global $wpdb;
Expand Down Expand Up @@ -2455,7 +2465,7 @@
/**
* Only admin can change main author
*/
if ( $courses_post_type === $post_type && ! current_user_can( 'administrator' ) ) {

Check failure on line 2468 in classes/Course.php

View workflow job for this annotation

GitHub Actions / WPCS

Capabilities should be used instead of roles. Found "administrator" in function call to current_user_can()
global $wpdb;
$post_ID = (int) tutor_utils()->avalue_dot( 'ID', $postarr );
$post_author = (int) $wpdb->get_var( $wpdb->prepare( "SELECT post_author FROM {$wpdb->posts} WHERE ID = %d ", $post_ID ) );
Expand Down Expand Up @@ -3584,4 +3594,82 @@
->attr( '@click', "TutorCore.modal.showModal('{$modal_id}')" )
->render();
}

/**
* Validate course content order
*
* @since 4.0.0
*
* @throws InvalidArgumentException If passing argument wrong.
* @throws Exception If discrepency found in topic of content ids.
*
* @param int $course_id The ID of the course.
* @param int $topic_id The ID of the topic.
* @param array $sorting_order The sorting order of the course contents.
* @param array $content_parent Parent topic & content ids.
*
* @return void
*/
private function validate_course_content_order( int $course_id, int $topic_id, array $sorting_order, array $content_parent = array() ): void {
if ( ! $course_id || ! $topic_id ) {
throw new InvalidArgumentException( esc_html__( 'Invalid course or topic ID', 'tutor' ) );
}

$provided_topic_ids = array();
$provided_content_ids = array();
foreach ( $sorting_order as $topic ) {
$provided_topic_ids[] = (int) $topic['topic_id'] ?? 0;

if ( ! empty( $topic['lesson_ids'] ) ) {
$provided_content_ids = array_merge( $provided_content_ids, $topic['lesson_ids'] );
}
}

if ( ! empty( $content_parent ) ) {
foreach ( $content_parent as $topic ) {
$provided_topic_ids[] = $topic['parent_topic_id'];
$provided_content_ids[] = $topic['content_id'];
}
}

$provided_topic_ids = array_values( array_unique( array_filter( $provided_topic_ids ) ) );
$provided_content_ids = array_values( array_unique( array_filter( $provided_content_ids ) ) );

if ( empty( $provided_topic_ids ) ) {
throw new InvalidArgumentException( esc_html__( 'No topics provided', 'tutor' ) );
}

$topic_ids = get_posts(
array(
'fields' => 'ids',
'post_parent' => $course_id,
'post_type' => tutor()->topics_post_type,
'post_status' => 'publish',
'posts_per_page' => -1,
)
);

$topic_id_diff = array_diff( $provided_topic_ids, $topic_ids );
if ( ! empty( $topic_id_diff ) ) {
throw new Exception( esc_html__( 'Invalid topic id provided', 'tutor' ) );
}

$default_post_types = array( tutor()->lesson_post_type, tutor()->quiz_post_type );
$content_post_types = array_unique( apply_filters( 'tutor_course_contents_post_types', $default_post_types ) );

$content_ids = get_posts(
array(
'fields' => 'ids',
'post_parent__in' => $topic_ids,
'post_status' => 'publish',
'post_type' => $content_post_types,
'posts_per_page' => -1,
)
);

$content_id_diff = array_diff( $provided_content_ids, $content_ids );
if ( ! empty( $content_id_diff ) ) {
throw new Exception( esc_html__( 'Invalid content id provided', 'tutor' ) );
}
}
}
29 changes: 13 additions & 16 deletions classes/Instructor.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
}
}

$validate_consent = LegalConsent::validate_consent( LegalConsent::DISPLAY_ON_INS_REG, $_POST );

Check failure on line 149 in classes/Instructor.php

View workflow job for this annotation

GitHub Actions / WPCS

Processing form data without nonce verification.
if ( is_wp_error( $validate_consent ) ) {
$validation_errors[ $validate_consent->get_error_code() ] = $validate_consent->get_error_message();
}
Expand Down Expand Up @@ -542,28 +542,25 @@

$cache_key = self::DASHBOARD_COURSE_COMPLETION_RATE_TRANSIENT . $user_id;

$cached_data = get_transient( $cache_key );
$cached_data = false; //get_transient( $cache_key );

Check failure on line 545 in classes/Instructor.php

View workflow job for this annotation

GitHub Actions / WPCS

Inline comments must end in full-stops, exclamation marks, or question marks

Check failure on line 545 in classes/Instructor.php

View workflow job for this annotation

GitHub Actions / WPCS

No space found before comment text; expected "// get_transient( $cache_key );" but found "//get_transient( $cache_key );"
if ( $cached_data && is_array( $cached_data ) ) {
return $cached_data;
}

$instructor_course_ids = array();
if ( ! User::is_admin() && User::is_instructor( $user_id, true ) ) {
$instructor_course_ids = CourseModel::get_courses_by_args(
array(
'post_author' => $user_id,
'posts_per_page' => -1,
'fields' => 'ids',
)
)->posts;
$instructor_course_ids = CourseModel::get_courses_by_args(
array(
'post_author' => $user_id,
'posts_per_page' => -1,
'fields' => 'ids',
)
)->posts;

$instructor_course_ids = array_filter(
array_map( 'absint', (array) $instructor_course_ids )
);
$instructor_course_ids = array_filter(
array_map( 'absint', (array) $instructor_course_ids )
);

if ( empty( $instructor_course_ids ) ) {
return $counts;
}
if ( empty( $instructor_course_ids ) ) {
return $counts;
}

$topic_type = tutor()->topics_post_type;
Expand Down Expand Up @@ -746,7 +743,7 @@

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$row = $wpdb->get_row(
$wpdb->prepare( $sql, $prepare_args ),

Check failure on line 746 in classes/Instructor.php

View workflow job for this annotation

GitHub Actions / WPCS

Use placeholders and $wpdb->prepare(); found $sql
ARRAY_A
);

Expand Down
5 changes: 4 additions & 1 deletion classes/Tutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@
*/
add_action( 'admin_init', array( $this, 'redirect_to_setup_page' ) );

// add_filter( 'rest_request_before_callbacks', array( $this, 'enforce_courses_privacy_rest_api' ), 10, 3 );

Check failure on line 592 in classes/Tutor.php

View workflow job for this annotation

GitHub Actions / WPCS

Inline comments must end in full-stops, exclamation marks, or question marks
}

/**
Expand Down Expand Up @@ -1148,7 +1148,7 @@
/**
* Add Instructor role to administrator
*/
if ( current_user_can( 'administrator' ) ) {

Check failure on line 1151 in classes/Tutor.php

View workflow job for this annotation

GitHub Actions / WPCS

Capabilities should be used instead of roles. Found "administrator" in function call to current_user_can()
tutor_utils()->add_instructor_role( get_current_user_id() );
}
}
Expand Down Expand Up @@ -1441,8 +1441,11 @@
$is_removed_private_items_permissions = get_option( 'tutor_removed_read_private_items_permission', false );

$role = get_role( tutor()->instructor_role );
if ( ! ( $role instanceof \WP_Role ) ) {
return;
}

if ( ! $is_removed_edit_other_items_permissions ) {
$role = get_role( tutor()->instructor_role );

$caps_to_be_removed = array(
'edit_others_tutor_courses',
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@
<testsuite name="lesson-test">
<directory>tests/phpunit/LessonContentInfoTest.php</directory>
</testsuite>
<testsuite name="course-test">
<directory>tests/phpunit/CourseTest.php</directory>
</testsuite>
</testsuites>
</phpunit>
25 changes: 23 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ Contributors: themeum
Donate link: https://tutorlms.com
Tags: lms, course, elearning, education, learning management system
Requires at least: 5.3
Tested up to: 7.0
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 4.0.7
Stable tag: 4.0.8
License: GPLv3
License URI: https://www.gnu.org/licenses/gpl-3.0.html

Expand Down Expand Up @@ -338,6 +338,27 @@ Tutor LMS allows you to offer certificates to your students upon course completi

== Changelog ==

= 4.0.8 - 07 Sep, 2026

Update: Add toggle settings for puzzle quiz background reference image. (Pro)
Update: UX updated for the assignment flow. (Pro)
Update: Add go-to content links in Q&A single views and deep-linkable course tabs.
Update: Site Header and Footer support in dashboard and learning area.
Update: Dark and Light Logo in settings.
Update: Quiz pagination and attempt details enhancement.
Update: Optimization of instructor dashboard db query to load page faster.
Update: Button Accessibility updated.
Fix: Google OAuth blank screen in incognito social login. (Pro)
Fix: Latex not rendering correctly on answer explanation.
Fix: Incorrect view of Category / tag in students dashboard.
Fix: Quiz Answer Option 0 Not Displaying on Frontend.
Fix: Reduce image display size in new quiz types views to eliminate inner scrolling. (Pro)
Fix: Course Prerequisite Missing After Section Removal. (Pro)
Fix: Content Bank – Invalid Question ID When Adding to Existing Quiz. (Pro)
Fix: Lesson Time Duration Not Saving/Updating.
Fix: Assignment attempt details attachment input should not be visible. (Pro)
Fix: Wrong display for total marks in Quiz.

= 4.0.7 - 20 Aug, 2026

Fix: Fixed course creation issues caused by the Multi Instructor addon. (Pro)
Expand Down
48 changes: 37 additions & 11 deletions restapi/RestAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,17 @@ public function __construct() {
* API auth.
*
* @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).
*
* @param int|false $user_id user id.
*
* @return int|false
*/
public function api_auth( $user_id ) {
// Don't authenticate twice.
if ( ! empty( $user_id ) || ! $this->is_tutor_api_request() ) {
if ( ! empty( $user_id ) || ! self::is_tutor_api_request() ) {
return $user_id;
}

Expand All @@ -104,30 +107,53 @@ public function api_auth( $user_id ) {
return $user_id;
}

$api_key = $_SERVER['PHP_AUTH_USER']; //phpcs:ignore sanitization ok
$api_secret = $_SERVER['PHP_AUTH_PW']; //phpcs:ignore sanitization ok
$api_key = sanitize_key( $_SERVER['PHP_AUTH_USER'] ) ?? '';
$api_secret = sanitize_key( $_SERVER['PHP_AUTH_PW'] ) ?? '';
$record = self::validate_api_key_secret( $api_key, $api_secret, true );
if ( $record ) {
return $record->user_id;

if ( ! $record ) {
return $user_id;
}

$meta = json_decode( $record->meta_value );
if ( ! is_object( $meta ) || ! isset( $meta->permission ) || self::ALL !== $meta->permission ) {
return $user_id;
}

return $user_id;
return (int) $record->user_id;
}

/**
* Is request is tutor rest api.
* 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.
*
* @return boolean
*/
public static function is_tutor_api_request() {
$rest_prefix = trailingslashit( rest_get_url_prefix() );
$request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
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 ) {
$path = trailingslashit( $path );
$rest_prefix = trailingslashit( rest_get_url_prefix() ); // e.g. wp-json/.
$needle = '/' . $rest_prefix . 'tutor/';

$is_tutor_api = ( false !== strpos( $request_uri, $rest_prefix . 'tutor/' ) );
if ( false !== strpos( $path, $needle ) ) {
return true;
}
}

return $is_tutor_api;
return false;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ function _manually_load_plugin() {

// Start up the WP testing environment.
require "{$_tests_dir}/includes/bootstrap.php";

// Initialize Tutor roles for testing environment.
if ( class_exists( 'TUTOR\Tutor' ) ) {
TUTOR\Tutor::manage_tutor_roles_and_permissions();
}

Loading
Loading