Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/tutor/GDPR/Controllers/BaseController.php
+++ b/tutor/GDPR/Controllers/BaseController.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Base controller class
+ *
+ * @package TutorGDPRControllers
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRControllers;
+
+/**
+ * Base controller for the consent controllers
+ */
+class BaseController {
+
+ /**
+ * Validate nonce and user capability for AJAX requests.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ protected function validate_ajax_request() {
+ tutor_utils()->check_nonce();
+ tutor_utils()->check_current_user_capability();
+ }
+}
--- a/tutor/GDPR/Controllers/LegalConsent.php
+++ b/tutor/GDPR/Controllers/LegalConsent.php
@@ -0,0 +1,930 @@
+<?php
+/**
+ * GDPR legal consent controller for managing consents.
+ *
+ * @package TutorGDPRControllers
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRControllers;
+
+use TutorGDPRModels{LegalConsents, LegalConsentLogs};
+use TutorHelpersValidationHelper;
+use TUTORInput;
+use TutorTraitsJsonResponse;
+use WP_Error;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GDPR AJAX controller for legal consents CRUD.
+ *
+ * @since 4.0.0
+ */
+class LegalConsent extends BaseController {
+
+ use JsonResponse;
+
+ /**
+ * Consent display places
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ const DISPLAY_ON_LOGIN = 'login';
+ const DISPLAY_ON_STD_REG = 'student_registration';
+ const DISPLAY_ON_INS_REG = 'instructor_registration';
+ const DISPLAY_ON_CHECKOUT = 'checkout';
+ const DISPLAY_ON_SUBSCRIPTION = 'subscription';
+ const DISPLAY_ON_ENROLLMENT = 'enrollment';
+
+ /**
+ * Consent method
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ const METHOD_MANDATORY_CHECK = 'mandatory_checkbox';
+ const METHOD_OPTIONAL_CHECK = 'optional_checkbox';
+ const METHOD_TEXT_ONLY = 'text_only';
+
+ /**
+ * Legal consent model.
+ *
+ * @since 4.0.0
+ *
+ * @var LegalConsents
+ */
+ private $model;
+
+ /**
+ * Consent update logs model.
+ *
+ * @since 4.0.0
+ *
+ * @var LegalConsentLogs
+ */
+ private $log_model;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.0.0
+ *
+ * @param bool $register_hooks Trigger hooks or not.
+ */
+ public function __construct( $register_hooks = true ) {
+ $this->model = new LegalConsents();
+ $this->log_model = new LegalConsentLogs();
+
+ if ( $register_hooks ) {
+ $this->register_hooks();
+ }
+ }
+
+ /**
+ * Register AJAX hooks.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ private function register_hooks() {
+ add_action( 'wp_ajax_tutor_gdpr_legal_consents', array( $this, 'handle_legal_consent_ajax' ) );
+ add_filter( 'tutor_localize_data', array( $this, 'extend_localize_data' ) );
+ add_action( 'tutor_login_form_end', array( $this, 'show_consent_field_on_login_form' ) );
+ }
+
+ /**
+ * Add legal consent display places to localized data.
+ *
+ * @since 4.0.0
+ *
+ * @param array $localize_data Localized data array.
+ *
+ * @return array
+ */
+ public function extend_localize_data( $localize_data ) {
+ $localize_data['legal_consent_display_places'] = self::get_consent_places();
+
+ return $localize_data;
+ }
+
+ /**
+ * Show consent field on the login form if available
+ *
+ * @since 4.0.0
+ */
+ public function show_consent_field_on_login_form() {
+ $consents = self::get_consent_by_display_key( self::DISPLAY_ON_LOGIN );
+ if ( tutor_utils()->count( $consents ) ) {
+ foreach ( $consents as $consent ) {
+ self::render_consent_field( $consent, 'tutor-mt-8 tutor-mb-24' );
+ }
+ }
+ }
+
+ /**
+ * Get the list of display places for legal consent.
+ *
+ * The list is filterable with the 'tutor_legal_consent_display_places' filter hook.
+ *
+ * @since 4.0.0
+ *
+ * @return array List of display place keys.
+ */
+ public static function get_consent_places() {
+ $places = array(
+ self::DISPLAY_ON_STD_REG,
+ self::DISPLAY_ON_LOGIN,
+ self::DISPLAY_ON_CHECKOUT,
+ );
+
+ $is_marketplace_enabled = tutor_utils()->get_option( 'enable_course_marketplace', false );
+
+ if ( $is_marketplace_enabled ) {
+ $places[] = self::DISPLAY_ON_INS_REG;
+ }
+
+ return apply_filters( 'tutor_legal_consent_display_places', $places );
+ }
+
+ /**
+ * Get display place options for the legal consent settings screen.
+ *
+ * @since 4.0.0
+ *
+ * @return array<string, string>
+ */
+ public static function get_display_place_options(): array {
+ $labels = array(
+ self::DISPLAY_ON_CHECKOUT => __( 'Checkout', 'tutor' ),
+ self::DISPLAY_ON_SUBSCRIPTION => __( 'Subscription', 'tutor' ),
+ self::DISPLAY_ON_ENROLLMENT => __( 'Enrollment', 'tutor' ),
+ );
+ $options = array();
+
+ foreach ( self::get_consent_places() as $place ) {
+ $options[ $place ] = $labels[ $place ] ?? ucwords( str_replace( '_', ' ', $place ) );
+ }
+
+ return $options;
+ }
+
+ /**
+ * Get consent method options for the legal consent settings screen.
+ *
+ * @since 4.0.0
+ *
+ * @return array<string, string>
+ */
+ public static function get_consent_method_options(): array {
+ return array(
+ self::METHOD_MANDATORY_CHECK => __( 'Mandatory Checkbox', 'tutor' ),
+ self::METHOD_OPTIONAL_CHECK => __( 'Optional Checkbox', 'tutor' ),
+ self::METHOD_TEXT_ONLY => __( 'Display Text Only', 'tutor' ),
+ );
+ }
+
+ /**
+ * Get legal consent items.
+ *
+ * @since 4.0.0
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public static function get_consents(): array {
+ $items = ( new self( false ) )->model->get_all( array() );
+
+ if ( ! is_array( $items ) ) {
+ return array();
+ }
+
+ $normalize_display_on = function ( $display_on ): array {
+ if ( is_array( $display_on ) ) {
+ return $display_on;
+ }
+
+ $display_on = array_filter( array_map( 'trim', explode( ',', (string) $display_on ) ) );
+ $normalized = array_combine( $display_on, $display_on );
+
+ if ( false === $normalized ) {
+ return array();
+ }
+
+ return $normalized;
+ };
+
+ $normalize_content_map = function ( $content_map ): array {
+ if ( is_array( $content_map ) ) {
+ return $content_map;
+ }
+
+ if ( ! is_string( $content_map ) || '' === $content_map ) {
+ return array();
+ }
+
+ $decoded = json_decode( $content_map, true );
+
+ return is_array( $decoded ) ? $decoded : array();
+ };
+
+ return array_map(
+ function ( $item ) use ( $normalize_content_map, $normalize_display_on ) {
+ $item = (array) $item;
+
+ return array(
+ 'id' => isset( $item['id'] ) ? (int) $item['id'] : 0,
+ 'enabled' => ! empty( $item['is_active'] ) ? 'on' : 'off',
+ 'title' => $item['consent_title'] ?? '',
+ 'display_on' => $normalize_display_on( $item['display_on'] ?? '' ),
+ 'message' => $item['consent_message'] ?? '',
+ 'method' => $item['consent_method'] ?? self::METHOD_MANDATORY_CHECK,
+ 'content_map' => $normalize_content_map( $item['consent_map'] ?? array() ),
+ );
+ },
+ $items
+ );
+ }
+
+ /**
+ * Handle legal consent CRUD AJAX requests.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ public function handle_legal_consent_ajax() {
+ $this->validate_ajax_request();
+
+ $action = Input::post( 'crud_action', '' );
+ $data = Input::sanitize_array( $_POST ); //phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce is validated.
+
+ switch ( $action ) {
+ case 'create':
+ $this->create_legal_consent( $data );
+ break;
+
+ case 'read':
+ $this->get_legal_consent( Input::post( 'id', 0, Input::TYPE_INT ) );
+ break;
+
+ case 'list':
+ $this->list_legal_consents( $data );
+ break;
+
+ case 'update':
+ $this->update_legal_consent( Input::post( 'id', 0, Input::TYPE_INT ), $data );
+ break;
+
+ case 'delete':
+ $this->delete_legal_consent( Input::post( 'id', 0, Input::TYPE_INT ) );
+ break;
+
+ default:
+ $this->response_fail( __( 'Invalid legal consent action.', 'tutor' ), 400 );
+ }
+ }
+
+ /**
+ * Get legal consents by scope
+ *
+ * @since 4.0.0
+ *
+ * @param string $place_key Place key like signup, signin, etc.
+ *
+ * @return array Consent places.
+ */
+ public static function get_consent_by_display_key( string $place_key ): array {
+ if ( ! in_array( $place_key, self::get_consent_places(), true ) ) {
+ return array();
+ }
+
+ $res = ( new self( false ) )->model->get_consents_by_display_key( $place_key );
+
+ return $res ? $res : array();
+ }
+
+ /**
+ * Create legal consent entry.
+ *
+ * @since 4.0.0
+ *
+ * @param array $request Request data.
+ *
+ * @return void
+ */
+ private function create_legal_consent( array $request ) {
+ global $wpdb;
+
+ $request['version'] = 1;
+
+ $data = $this->prepare_legal_consent_data( $request, true );
+
+ if ( is_wp_error( $data ) ) {
+ $this->json_response( '', $data->errors, 400 );
+ }
+
+ $consent_map = tutor_is_json( $data['consent_map'] ) ? $data['consent_map'] : null;
+ if ( is_null( $consent_map ) ) {
+ $this->json_response( __( 'Invalid consent map', 'tutor' ), '', 400 );
+ }
+
+ $wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $legal_consent_id = $this->model->create( $data );
+ if ( ! $legal_consent_id ) {
+ $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $this->response_fail( __( 'Failed to create legal consent.', 'tutor' ), 500 );
+ }
+
+ $log_id = $this->log_model->create(
+ array(
+ 'legal_consent_id' => (int) $legal_consent_id,
+ 'action' => 'created',
+ 'old_data' => null,
+ 'new_data' => wp_json_encode( $data ),
+ 'created_at_gmt' => current_time( 'mysql', true ),
+ )
+ );
+
+ if ( ! $log_id ) {
+ $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $this->response_fail( __( 'Failed to create legal consent log.', 'tutor' ), 500 );
+ }
+
+ $wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $this->json_response(
+ __( 'Legal consent created successfully.', 'tutor' ),
+ array(
+ 'id' => $legal_consent_id,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Get single legal consent.
+ *
+ * @since 4.0.0
+ *
+ * @param int $id Legal consent ID.
+ *
+ * @return void
+ */
+ private function get_legal_consent( int $id ) {
+ if ( ! $id ) {
+ $this->response_fail( __( 'Invalid legal consent id.', 'tutor' ), 400 );
+ }
+
+ $item = $this->model->get_row( array( 'id' => $id ) );
+ if ( ! $item ) {
+ $this->response_fail( __( 'Legal consent not found.', 'tutor' ), 404 );
+ }
+
+ $this->response_data( $item );
+ }
+
+ /**
+ * Get legal consent list.
+ *
+ * @since 4.0.0
+ *
+ * @param array $request Request data.
+ *
+ * @return void
+ */
+ private function list_legal_consents( array $request ) {
+ $where = array();
+
+ $consent_title = $request['consent_title'] ?? '';
+ if ( ! empty( $consent_title ) ) {
+ $where['consent_title'] = Input::sanitize( $consent_title );
+ }
+
+ if ( Input::has( 'is_active' ) ) {
+ $where['is_active'] = (int) $request['is_active'];
+ }
+
+ $items = $this->model->get_all( $where );
+ $this->response_data( $items );
+ }
+
+ /**
+ * Update legal consent entry.
+ *
+ * @since 4.0.0
+ *
+ * @param int $id Legal consent ID.
+ * @param array $request Request data.
+ *
+ * @return void
+ */
+ private function update_legal_consent( int $id, array $request ) {
+ global $wpdb;
+
+ if ( ! $id ) {
+ $this->response_fail( __( 'Invalid legal consent id.', 'tutor' ), 400 );
+ }
+
+ $existing = $this->model->get_row( array( 'id' => $id ) );
+ if ( ! $existing ) {
+ $this->response_fail( __( 'Legal consent not found.', 'tutor' ), 404 );
+ }
+
+ $data = $this->prepare_legal_consent_data( $request, false );
+ if ( is_wp_error( $data ) ) {
+ $this->response_bad_request( __( 'Validation error', 'tutor' ), $data->errors, 400 );
+ }
+
+ if ( isset( $data['consent_map'] ) && ! tutor_is_json( $data['consent_map'] ) ) {
+ $this->response_fail( __( 'Invalid consent map.', 'tutor' ), 400 );
+ }
+
+ if ( empty( $data ) ) {
+ $this->response_fail( __( 'No update data found.', 'tutor' ), 400 );
+ }
+
+ $wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $data['version'] = (int) $existing->version + 1;
+ $data['updated_at_gmt'] = current_time( 'mysql', true );
+ $updated = $this->model->update( $id, $data );
+ if ( ! $updated ) {
+ $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $this->response_fail( __( 'Failed to update legal consent.', 'tutor' ), 500 );
+ }
+
+ $new_data = array_merge( (array) $existing, $data );
+
+ $log_id = $this->log_model->create(
+ array(
+ 'legal_consent_id' => $id,
+ 'action' => 'updated',
+ 'old_data' => wp_json_encode( (array) $existing ),
+ 'new_data' => wp_json_encode( $new_data ),
+ 'created_at_gmt' => current_time( 'mysql', true ),
+ )
+ );
+ if ( ! $log_id ) {
+ $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $this->response_fail( __( 'Failed to create legal consent log.', 'tutor' ), 500 );
+ }
+
+ $wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $this->response_success( __( 'Legal consent updated successfully.', 'tutor' ) );
+ }
+
+ /**
+ * Delete legal consent entry.
+ *
+ * @since 4.0.0
+ *
+ * @param int $id Legal consent ID.
+ *
+ * @return void
+ */
+ private function delete_legal_consent( int $id ) {
+ global $wpdb;
+
+ if ( ! $id ) {
+ $this->response_fail( __( 'Invalid legal consent id.', 'tutor' ), 400 );
+ }
+
+ $existing = $this->model->get_row( array( 'id' => $id ) );
+ if ( ! $existing ) {
+ $this->response_fail( __( 'Legal consent not found.', 'tutor' ), 404 );
+ }
+
+ $wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $deleted = $this->model->delete( $id );
+ if ( ! $deleted ) {
+ $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $this->response_fail( __( 'Failed to delete legal consent.', 'tutor' ), 500 );
+ }
+
+ $log_id = $this->log_model->create(
+ array(
+ 'legal_consent_id' => $id,
+ 'action' => 'deleted',
+ 'old_data' => wp_json_encode( (array) $existing ),
+ 'new_data' => null,
+ 'created_at_gmt' => current_time( 'mysql', true ),
+ )
+ );
+ if ( ! $log_id ) {
+ $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $this->response_fail( __( 'Failed to create legal consent log.', 'tutor' ), 500 );
+ }
+
+ $wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+
+ $this->response_success( __( 'Legal consent deleted successfully.', 'tutor' ) );
+ }
+
+ /**
+ * Prepare and validate legal consent payload.
+ *
+ * @since 4.0.0
+ *
+ * @param array $request Request data.
+ * @param bool $is_create True for create operation.
+ *
+ * @return array|WP_Error
+ */
+ private function prepare_legal_consent_data( array $request, bool $is_create ) {
+ $request = array_intersect_key( $request, array_flip( $this->model->get_fillable_fields() ) );
+
+ $data = array(
+ 'consent_title' => Input::sanitize( $request['consent_title'] ?? '', '', Input::TYPE_STRING ),
+ 'display_on' => Input::sanitize( $request['display_on'] ?? '', '', Input::TYPE_STRING ),
+ 'consent_message' => Input::sanitize( $request['consent_message'] ?? '', '', Input::TYPE_KSES_POST ),
+ 'consent_map' => Input::sanitize( $request['consent_map'] ?? '', '', Input::TYPE_STRING ),
+ 'version' => Input::sanitize( $request['version'] ?? '', '', Input::TYPE_STRING ),
+ 'consent_method' => Input::sanitize( $request['consent_method'] ?? '' ),
+ 'is_active' => (int) Input::sanitize( $request['is_active'] ?? true, true, Input::TYPE_BOOL ),
+ 'settings' => $this->sanitize_json_field( $request['settings'] ?? '' ),
+ );
+
+ if ( $is_create ) {
+ $validation = ValidationHelper::validate( $this->get_legal_consent_validation_rules(), $data );
+ if ( ! $validation->success ) {
+ return new WP_Error( 'validation_error', $validation->errors );
+ }
+
+ $data['created_at_gmt'] = current_time( 'mysql', true );
+ } else {
+ $data = array_filter(
+ $request,
+ function ( $value ) {
+ return '' !== $value && null !== $value;
+ }
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get legal consent validation rules.
+ *
+ * @since 4.0.0
+ *
+ * @return array
+ */
+ private function get_legal_consent_validation_rules(): array {
+ return array(
+ 'consent_title' => 'required',
+ 'display_on' => 'required',
+ 'consent_message' => 'required',
+ 'version' => 'required',
+ 'consent_method' => 'required',
+ );
+ }
+
+ /**
+ * Sanitize JSON-like settings field.
+ *
+ * @since 4.0.0
+ *
+ * @param mixed $value Settings input.
+ *
+ * @return string|null
+ */
+ private function sanitize_json_field( $value ) {
+ if ( is_array( $value ) ) {
+ return wp_json_encode( $value );
+ }
+
+ $value = is_string( $value ) ? trim( wp_unslash( $value ) ) : '';
+ if ( '' === $value ) {
+ return null;
+ }
+
+ $decoded = json_decode( $value, true );
+ return JSON_ERROR_NONE === json_last_error() ? wp_json_encode( $decoded ) : sanitize_text_field( $value );
+ }
+
+ /**
+ * Render consent field markup.
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent settings object.
+ * @param string $wrapper_cs_class Wrapper css class for styling.
+ *
+ * @return void
+ */
+ public static function render_consent_field( object $consent, string $wrapper_cs_class = '' ): void {
+ if ( ! $consent->is_active ) {
+ return;
+ }
+
+ $allowed_places = self::get_consent_places();
+
+ // Normalize display_on to array.
+ if ( is_array( $consent->display_on ) ) {
+ $display_on = array_map( 'strval', array_values( $consent->display_on ) );
+ } else {
+ $display_on = array_filter( array_map( 'trim', explode( ',', (string) ( $consent->display_on ?? '' ) ) ) );
+ }
+
+ if ( empty( array_intersect( $display_on, $allowed_places ) ) ) {
+ return;
+ }
+
+ $is_required = self::is_required( $consent );
+ $is_text_only = self::METHOD_TEXT_ONLY === $consent->consent_method;
+ $field_name = self::get_field_name( $consent );
+
+ ?>
+ <div class="tutor-form-row <?php echo esc_attr( $wrapper_cs_class ); ?>">
+ <div class="tutor-input-field">
+ <div class="tutor-input-wrapper tutor-form-check tutor-d-flex" style="align-items: start;">
+ <?php if ( ! $is_text_only ) : ?>
+ <input type="checkbox" id="<?php echo esc_attr( $field_name ); ?>" name="<?php echo esc_attr( $field_name ); ?>" class="tutor-checkbox tutor-checkbox-md tutor-form-check-input" style="margin-top: 2px!important;" <?php echo esc_attr( $is_required ? 'required' : '' ); ?>>
+ <?php endif; ?>
+ <label for="<?php echo esc_attr( $field_name ); ?>" class="tutor-label">
+ <?php self::render_constructed_label_text( $consent ); ?>
+ </label>
+ </div>
+ </div>
+ </div>
+ <?php
+ }
+
+ /**
+ * Check whether a consent is required.
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent settings object.
+ *
+ * @return bool
+ */
+ public static function is_required( object $consent ): bool {
+ return self::METHOD_MANDATORY_CHECK === $consent->consent_method;
+ }
+
+ /**
+ * Check whether a consent is active.
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent settings object.
+ *
+ * @return bool
+ */
+ public static function is_active( object $consent ): bool {
+ $is_active = (int) $consent->is_active;
+ return $is_active ? true : false;
+ }
+
+ /**
+ * Check whether a consent is text only without checkbox.
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent settings object.
+ *
+ * @return bool
+ */
+ public static function is_text_only( object $consent ): bool {
+ return self::METHOD_TEXT_ONLY === $consent->consent_method;
+ }
+
+ /**
+ * Get the consent field name
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent settings object.
+ *
+ * @return string
+ */
+ public static function get_field_name( object $consent ): string {
+ return strtolower( str_replace( ' ', '_', $consent->consent_title . '_' . $consent->id ) );
+ }
+
+
+ /**
+ * Render consent label text with optional linked placeholders.
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent settings object.
+ *
+ * @return void
+ */
+ private static function render_constructed_label_text( object $consent ): void {
+ if ( empty( $consent ) || empty( $consent->consent_message ) ) {
+ return;
+ }
+
+ // Decode message (fix & etc).
+ $message = html_entity_decode( $consent->consent_message );
+
+ // Normalize map (JSON → array).
+ $map = is_array( $consent->consent_map )
+ ? $consent->consent_map
+ : json_decode( $consent->consent_map, true );
+
+ if ( empty( $map ) || ! is_array( $map ) ) {
+ echo esc_html( $message );
+ return;
+ }
+
+ // Find all {placeholders}.
+ preg_match_all( '/{([a-zA-Z0-9_-]+)}/', $message, $matches );
+
+ if ( empty( $matches[1] ) ) {
+ echo esc_html( $message );
+ return;
+ }
+
+ foreach ( $matches[1] as $key ) {
+
+ if ( empty( $map[ $key ] ) ) {
+ continue;
+ }
+
+ $page_id = (int) $map[ $key ];
+
+ if ( ! $page_id || 'publish' !== get_post_status( $page_id ) ) {
+ continue;
+ }
+
+ $url = get_permalink( $page_id );
+ $title = get_the_title( $page_id );
+
+ $anchor = sprintf(
+ '<a href="%s" target="_blank" rel="noopener noreferrer" class="tutor-consent-link" style="display:contents;">%s</a>',
+ esc_url( $url ),
+ esc_html( $title )
+ );
+
+ $message = str_replace( '{' . $key . '}', $anchor, $message );
+ }
+
+ add_filter(
+ 'safe_style_css',
+ function ( $styles ) {
+ $styles[] = 'display';
+ return $styles;
+ }
+ );
+
+ echo wp_kses(
+ $message,
+ array(
+ 'a' => array(
+ 'href' => array(),
+ 'target' => array(),
+ 'rel' => array(),
+ 'class' => array(),
+ 'style' => true,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Check if the display place has consent & validate it
+ *
+ * @since 4.0.0
+ *
+ * @param string $display_key Display key.
+ * @param array $request Input request.
+ *
+ * @return WP_Error|array WP_Error when the consent is required but not present in the req. Array
+ * contain the given consent fields.
+ */
+ public static function validate_consent( string $display_key, array $request ) {
+ $consents = self::get_consent_by_display_key( $display_key );
+
+ // Keep the fields where user has given consent.
+ $res = array();
+
+ if ( tutor_utils()->count( $consents ) ) {
+ foreach ( $consents as $consent ) {
+ $is_active = (int) $consent->is_active;
+ if ( ! $is_active ) {
+ continue;
+ }
+
+ $field_name = self::get_field_name( $consent );
+ $is_required = self::is_required( $consent );
+ $is_checked = $request[ $field_name ] ?? 0;
+
+ if ( $is_required && ! $is_checked ) {
+ return new WP_Error( 'consent_error', __( 'Please accept the consent field', 'tutor' ) );
+ }
+
+ if ( $is_checked ) {
+ array_push( $res, $field_name );
+ }
+ }
+ } else {
+ $terms_conditions_link = tutor_utils()->get_toc_page_link();
+ if ( $terms_conditions_link ) {
+ $is_checked = self::DISPLAY_ON_CHECKOUT === $display_key
+ ? ( $request['agree_to_terms'] ?? 0 )
+ : ( $request['terms_conditions'] ?? 0 );
+ if ( ! $is_checked ) {
+ $required_fields['terms_conditions'] = __( 'Please accept the Terms and Conditions to continue', 'tutor' );
+ }
+
+ array_push( $res, self::DISPLAY_ON_CHECKOUT === $display_key ? 'agree_to_terms' : 'terms_conditions' );
+ }
+ }
+
+ return $res;
+ }
+
+ /**
+ * Build a snapshot of the consent message and associated links.
+ *
+ * This method decodes the consent message, replaces any placeholders with finalized anchor tags,
+ * and constructs a links snapshot containing details about the linked pages.
+ *
+ * @since 4.0.0
+ *
+ * @param object $consent Consent object containing message and map details.
+ *
+ * @return array Array containing the processed consent message and the links snapshot.
+ */
+ public static function build_consent_snapshot( object $consent ): array {
+ if ( empty( $consent ) || empty( $consent->consent_message ) ) {
+ return array();
+ }
+
+ // Decode message.
+ $message = html_entity_decode( $consent->consent_message );
+
+ // Normalize map.
+ $map = is_array( $consent->consent_map )
+ ? $consent->consent_map
+ : json_decode( $consent->consent_map, true );
+
+ $links_snapshot = array();
+
+ // Replace placeholders with final anchors.
+ preg_match_all( '/{([a-zA-Z0-9_-]+)}/', $message, $matches );
+
+ if ( ! empty( $matches[1] ) && is_array( $map ) ) {
+
+ foreach ( $matches[1] as $key ) {
+
+ if ( empty( $map[ $key ] ) ) {
+ continue;
+ }
+
+ $page_id = (int) $map[ $key ];
+
+ if ( ! $page_id || 'publish' !== get_post_status( $page_id ) ) {
+ continue;
+ }
+
+ $url = get_permalink( $page_id );
+ $title = get_the_title( $page_id );
+
+ // Build anchor (snapshot should NOT depend on future changes).
+ $anchor = sprintf(
+ '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
+ esc_url( $url ),
+ esc_html( $title )
+ );
+
+ $message = str_replace( '{' . $key . '}', $anchor, $message );
+
+ // Store link snapshot separately (optional but powerful).
+ $links_snapshot[ $key ] = array(
+ 'page_id' => $page_id,
+ 'url' => $url,
+ 'title' => $title,
+ );
+ }
+ }
+
+ $plain_text = wp_strip_all_tags( $message );
+
+ return array(
+ 'consent_title' => $consent->consent_title ?? '',
+ 'version' => $consent->version ?? 1,
+ 'label_snapshot' => $message, // PRIMARY legal proof.
+ 'label_snapshot_plain_text' => $plain_text, // Fallback plain text.
+ 'links_snapshot' => wp_json_encode( $links_snapshot ),
+ 'consent_method' => $consent->consent_method ?? null,
+ 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ),
+ 'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
+ 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
+ );
+ }
+}
--- a/tutor/GDPR/Controllers/UserConsent.php
+++ b/tutor/GDPR/Controllers/UserConsent.php
@@ -0,0 +1,430 @@
+<?php
+/**
+ * GDPR user content controller.
+ *
+ * @package TutorGDPRControllers
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRControllers;
+
+use Exception;
+use TutorGDPRModelsUserConsents;
+use TutorHelpersValidationHelper;
+use TUTORInput;
+use TutorTraitsJsonResponse;
+use WP_User;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GDPR AJAX controller for user contents CRUD.
+ *
+ * @since 4.0.0
+ */
+class UserConsent extends BaseController {
+
+ use JsonResponse;
+
+ /**
+ * User contents model.
+ *
+ * @since 4.0.0
+ *
+ * @var UserContents
+ */
+ private $model;
+
+ /**
+ * Constructor.
+ *
+ * @since 4.0.0
+ *
+ * @param bool $register_hooks When to trigger hook or not.
+ */
+ public function __construct( bool $register_hooks = true ) {
+ $this->model = new UserConsents();
+
+ if ( $register_hooks ) {
+ $this->register_hooks();
+ }
+ }
+
+ /**
+ * Register AJAX hooks.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ private function register_hooks() {
+ add_action( 'tutor_new_user_registered', array( $this, 'store_registration_consent' ), 10, 2 );
+ add_action( 'tutor_new_instructor_registered', array( $this, 'store_instructor_registration_consent' ), 10, 2 );
+ add_action( 'tutor_after_login_success', array( $this, 'store_login_consent' ), 10, 2 );
+ add_action( 'tutor_after_checkout_consent', array( $this, 'store_checkout_consent' ), 10, 2 );
+ 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_filter( 'manage_users_columns', array( $this, 'add_consent_logs_column' ) );
+ add_filter( 'manage_users_custom_column', array( $this, 'render_consent_logs_column' ), 10, 3 );
+ }
+
+ /**
+ * Add consent logs column to user list table.
+ *
+ * @since 4.0.0
+ *
+ * @param array $columns User list table columns.
+ *
+ * @return array
+ */
+ 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;
+ }
+
+ /**
+ * Render consent logs column.
+ *
+ * @since 4.0.0
+ *
+ * @param string $value Column value.
+ * @param string $column_name Column name.
+ * @param int $user_id User ID.
+ */
+ public function render_consent_logs_column( $value, $column_name, $user_id ) {
+ if ( 'consent_logs' !== $column_name ) {
+ return $value;
+ }
+
+ $user = get_userdata( $user_id );
+
+ if ( ! $user ) {
+ return $value;
+ }
+
+ $value = '<button type="button" class="tutor-btn tutor-btn-outline-primary tutor-btn-sm" data-tutor-modal-target="tutor-consent-logs-modal" data-consent-logs-trigger data-user-id="' . esc_attr( $user_id ) . '" data-user-name="' . esc_attr( $user->display_name ) . '" data-user-joined="' . esc_attr( $user->user_registered ) . '" data-user-email="' . esc_attr( $user->user_email ) . '" data-user-login="' . esc_attr( $user->user_login ) . '" data-avatar-src="' . esc_url( tutor_utils()->get_user_avatar_url( $user_id ) ) . '"><i class="tutor-icon-eye-line tutor-mr-8" aria-hidden="true"></i>' . esc_html__( 'View Logs', 'tutor' ) . '</button>';
+
+ return $value;
+ }
+
+ /**
+ * Store registration consent
+ *
+ * @since 4.0.0
+ *
+ * @param WP_User $user User object.
+ * @param array $checked_consents The provided consent fields.
+ *
+ * @return void
+ */
+ public function store_registration_consent( WP_User $user, array $checked_consents ): void {
+ $this->create_user_consent( $user->ID, LegalConsent::DISPLAY_ON_STD_REG, $checked_consents );
+ }
+
+ /**
+ * Store instructor registration consent.
+ *
+ * @since 4.0.0
+ *
+ * @param int $user_id User id.
+ * @param array $checked_consents The provided consent fields.
+ *
+ * @return void
+ */
+ public function store_instructor_registration_consent( int $user_id, array $checked_consents ): void {
+ $this->create_user_consent( $user_id, LegalConsent::DISPLAY_ON_INS_REG, $checked_consents );
+ }
+
+ /**
+ * Store login consent
+ *
+ * @since 4.0.0
+ *
+ * @param int $user_id User id.
+ * @param array $checked_consents The provided consent fields.
+ *
+ * @return void
+ */
+ public function store_login_consent( int $user_id, array $checked_consents ): void {
+ $this->create_user_consent( $user_id, LegalConsent::DISPLAY_ON_LOGIN, $checked_consents );
+ }
+
+ /**
+ * Store checkout consent.
+ *
+ * @since 4.0.0
+ *
+ * @param int $user_id User id.
+ * @param array $checked_consents The provided consent fields.
+ *
+ * @return void
+ */
+ public function store_checkout_consent( int $user_id, array $checked_consents ): void {
+ $this->create_user_consent( $user_id, LegalConsent::DISPLAY_ON_CHECKOUT, $checked_consents );
+ }
+
+ /**
+ * Handle ajax request
+ *
+ * @since 4.0.0
+ *
+ * @return void Send json response
+ */
+ public function handle_ajax_request(): void {
+ $this->validate_ajax_request();
+
+ $user_action = Input::post( 'user_action' );
+
+ switch ( $user_action ) {
+ case 'all_consents_given_by_user':
+ $user_id = Input::post( 'user_id', 0, Input::TYPE_INT );
+
+ $validate_user = ValidationHelper::validate(
+ array( 'user_id' => 'required|is_exists' ),
+ array( 'user_id' => $user_id )
+ );
+
+ if ( ! $validate_user->success ) {
+ $this->response_bad_request( __( 'Invalid user ID', 'tutor' ) );
+ }
+
+ $consents = $this->get_all_consents_given_by_user( $user_id );
+
+ $this->json_response(
+ __( 'Consent fetched successfully', 'tutor' ),
+ $consents
+ );
+
+ break;
+ default:
+ $this->response_bad_request( __( 'Invalid action', 'tutor' ) );
+ break;
+ }
+ }
+
+
+ /**
+ * Check if the user has already given consent for a specific display key and version.
+ *
+ * @since 4.0.0
+ *
+ * @param int $user_id ID of the user.
+ * @param string $display_key Consent display key (e.g., registration, login).
+ * @param array $checked_consents Checked consent fields.
+ *
+ * @return void
+ */
+ private function create_user_consent( int $user_id, string $display_key, array $checked_consents ) {
+ $user_data = get_userdata( $user_id );
+ if ( $user_data ) {
+ $consents = LegalConsent::get_consent_by_display_key( $display_key );
+
+ if ( tutor_utils()->count( $consents ) ) {
+ foreach ( $consents as $consent ) {
+ $is_active = LegalConsent::is_active( $consent );
+
+ $args = array(
+ 'source' => $display_key,
+ 'version' => $consent->version,
+ 'user_id' => $user_data->ID,
+ 'consent_title' => $consent->consent_title,
+ );
+
+ $already_given = $this->model->get_row( $args );
+
+ if ( ! $is_active || $already_given ) {
+ continue;
+ }
+
+ $is_text_only = LegalConsent::is_text_only( $consent );
+ if ( $is_text_only ) {
+ // Store consent.
+ $this->build_and_store( $consent, $user_data, $display_key );
+ } else {
+ $consent_field = LegalConsent::get_field_name( $consent );
+ $is_checked_consent = in_array( $consent_field, $checked_consents, true );
+
+ if ( ! $is_checked_consent ) {
+ continue;
+ }
+
+ $this->build_and_store( $consent, $user_data, $display_key );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieve all consents given by a specific user.
+ *
+ * @since 4.0.0
+ *
+ * @param int $user_id ID of the user.
+ *
+ * @return array Array of user consent records.
+ */
+ private function get_all_consents_given_by_user( int $user_id ): array {
+ $where = array(
+ 'user_id' => $user_id,
+ );
+
+ $records = $this->model->get_all( $where );
+ if ( ! is_array( $records ) ) {
+ return array();
+ }
+
+ $records = array_map(
+ function ( $record ) {
+ if ( ! isset( $record->created_at_gmt ) ) {
+ return $record;
+ }
+
+ $created_at = strtotime( $record->created_at_gmt . ' UTC' );
+ if ( false === $created_at ) {
+ return $record;
+ }
+
+ $record->time_ago = sprintf(
+ /* translators: %s human-readable time difference. */
+ __( '%s ago', 'tutor' ),
+ human_time_diff( $created_at, time() )
+ );
+
+ return $record;
+ },
+ $records
+ );
+
+ return $records;
+ }
+
+ /**
+ * Build and store give consent
+ *
+ * @since 4.0.0
+ *
+ * @param Object $consent Consent object.
+ * @param WP_User $user_data User data object.
+ * @param string $display_key Display key.
+ */
+ 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;
+ $build_consent['source'] = $display_key;
+
+ try {
+ $this->create( $build_consent );
+ } catch ( Throwable $th ) {
+ tutor_log( $th );
+ }
+ }
+ }
+
+ /**
+ * Create user content entry.
+ *
+ * @since 4.0.0
+ *
+ * @throws Exception If failed to store consent.
+ *
+ * @param array $data Request data.
+ *
+ * @return int On success consent id
+ */
+ private function create( array $data ) {
+ $user_consent_id = $this->model->create( $data );
+ if ( ! $user_consent_id ) {
+ throw new Exception( esc_html__( 'Failed to store consent', 'tutor' ) );
+ }
+
+ 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.
+ *
+ * @since 4.0.0
+ *
+ * @param object $user_data User list item object.
+ */
+ public function render_consent_logs_button( $user_data ): void {
+ $user_id = $user_data->ID ?? 0;
+ 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 ) );
+ ?>
+ <div class="tutor-dropdown-parent">
+ <button type="button" class="tutor-iconic-btn" action-tutor-dropdown="toggle">
+ <span class="tutor-icon-kebab-menu" aria-hidden="true"></span>
+ </button>
+ <div id="user-actions-<?php echo esc_attr( $user_id ); ?>" class="tutor-dropdown tutor-dropdown-dark tutor-text-left">
+ <button
+ type="button"
+ class="tutor-dropdown-item"
+ data-tutor-modal-target="tutor-consent-logs-modal"
+ data-consent-logs-trigger
+ data-user-id="<?php echo esc_attr( $user_id ); ?>"
+ data-user-name="<?php echo esc_attr( $user_name ); ?>"
+ data-user-joined="<?php echo esc_attr( $user_joined ); ?>"
+ data-user-email="<?php echo esc_attr( $user_email ); ?>"
+ data-user-login="<?php echo esc_attr( $user_login ); ?>"
+ data-avatar-src="<?php echo esc_url( $avatar_src ); ?>"
+ >
+ <i class="tutor-icon-file-text tutor-mr-8" aria-hidden="true"></i>
+ <span><?php esc_html_e( 'Consent Logs', 'tutor' ); ?></span>
+ </button>
+ </div>
+ </div>
+ <?php
+ }
+
+ /**
+ * Render consent logs modal.
+ * Called via action hook.
+ *
+ * @since 4.0.0
+ */
+ public function render_consent_logs_modal(): void {
+ include tutor()->path . 'views/templates/consent-logs-modal.php';
+ }
+}
--- a/tutor/GDPR/DB/DB.php
+++ b/tutor/GDPR/DB/DB.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * GDPR DB base class.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Abstract GDPR DB table class.
+ */
+abstract class DB {
+
+ /**
+ * Create all GDPR tables.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ public static function create_tables() {
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ foreach ( static::tables() as $table_class ) {
+ $sql = $table_class::get_schema();
+ if ( ! empty( $sql ) ) {
+ dbDelta( $sql );
+ }
+ }
+ }
+
+ /**
+ * Drop all GDPR tables.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ public static function drop_tables() {
+ global $wpdb;
+
+ foreach ( static::tables() as $table_class ) {
+ $table_name = $table_class::get_table_name();
+ $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ }
+ }
+
+ /**
+ * Registered GDPR table classes.
+ *
+ * @since 4.0.0
+ *
+ * @return array
+ */
+ protected static function tables() {
+ return array(
+ LegalConsents::class,
+ UserConsents::class,
+ Logs::class,
+ );
+ }
+
+ /**
+ * Get table name.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ abstract public static function get_table_name();
+
+ /**
+ * Get create table SQL.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ abstract public static function get_schema();
+}
--- a/tutor/GDPR/DB/LegalConsents.php
+++ b/tutor/GDPR/DB/LegalConsents.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * GDPR legal consents table.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Legal consents table class.
+ */
+class LegalConsents extends DB {
+
+ /**
+ * Get table name.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ public static function get_table_name() {
+ global $wpdb;
+ return $wpdb->prefix . 'tutor_legal_consents';
+ }
+
+ /**
+ * Get create table schema.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ public static function get_schema() {
+ global $wpdb;
+
+ $table_name = static::get_table_name();
+ $charset_collate = $wpdb->get_charset_collate();
+
+ return "CREATE TABLE {$table_name} (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ consent_title VARCHAR(255) NOT NULL,
+ display_on TEXT NOT NULL, -- comma separate value for multiple scopes
+ consent_message TEXT NOT NULL,
+ consent_map JSON, -- JSON map [terms_conditions => 1]
+ 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)
+ ) {$charset_collate};";
+ }
+}
--- a/tutor/GDPR/DB/Logs.php
+++ b/tutor/GDPR/DB/Logs.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * GDPR compliance logs table.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Consents table class.
+ */
+class Logs extends DB {
+
+ /**
+ * Get table name.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ public static function get_table_name() {
+ global $wpdb;
+ return $wpdb->prefix . 'tutor_legal_consent_logs';
+ }
+
+ /**
+ * Get create table schema.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ public static function get_schema() {
+ global $wpdb;
+
+ $table_name = static::get_table_name();
+ $charset_collate = $wpdb->get_charset_collate();
+
+ return "CREATE TABLE {$table_name} (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ legal_consent_id BIGINT UNSIGNED NOT NULL,
+ action VARCHAR(50), -- created, updated, deleted
+ old_data JSON NULL,
+ new_data JSON NULL,
+ created_at_gmt DATETIME NOT NULL
+ ) {$charset_collate};";
+ }
+}
--- a/tutor/GDPR/DB/UserConsents.php
+++ b/tutor/GDPR/DB/UserConsents.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * GDPR user contents table.
+ *
+ * @package TutorGDPRDB
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRDB;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * User contents table class.
+ */
+class UserConsents extends DB {
+
+ /**
+ * Get table name.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ public static function get_table_name() {
+ global $wpdb;
+ return $wpdb->prefix . 'tutor_user_consents';
+ }
+
+ /**
+ * Get create table schema.
+ *
+ * @since 4.0.0
+ *
+ * @return string
+ */
+ public static function get_schema() {
+ global $wpdb;
+
+ $table_name = static::get_table_name();
+ $charset_collate = $wpdb->get_charset_collate();
+
+ return "CREATE TABLE {$table_name} (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT UNSIGNED NULL,
+ user_email VARCHAR(190) NOT NULL,
+ consent_title VARCHAR(100) NOT NULL,
+ label_snapshot TEXT NOT NULL,
+ links_snapshot JSON,
+ version VARCHAR(20) NOT NULL,
+ consent_method VARCHAR(255) NOT NULL,
+ ip_address VARCHAR(45),
+ user_agent TEXT,
+ source VARCHAR(50), -- consent page info
+ created_at_gmt DATETIME NOT NULL,
+ INDEX (user_id),
+ INDEX (consent_title),
+ INDEX (created_at_gmt)
+ ) {$charset_collate};";
+ }
+}
--- a/tutor/GDPR/GDPR.php
+++ b/tutor/GDPR/GDPR.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * Main GDPR module bootstrap.
+ *
+ * @package TutorGDPR
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPR;
+
+use AllowDynamicProperties;
+use TutorGDPRControllersLegalConsent;
+use TutorGDPRControllersUserConsent;
+use TutorGDPRDBDB;
+use TUTORSingleton;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GDPR main class to init GDPR functionalities.
+ *
+ * @since 4.0.0
+ */
+#[AllowDynamicProperties]
+final class GDPR extends Singleton {
+
+ /**
+ * Option key to track GDPR DB schema installation.
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ private const DB_SCHEMA_VERSION_OPTION = 'tutor_gdpr_db_schema_version';
+
+ /**
+ * Current schema version.
+ *
+ * Bump this when GDPR DB schemas change.
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ private const DB_SCHEMA_VERSION = '1.1.0';
+
+ /**
+ * Constructor.
+ *
+ * @since 4.0.0
+ */
+ public function __construct() {
+ $this->register_hooks();
+ }
+
+ /**
+ * Register WordPress hooks for GDPR.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ private function register_hooks() {
+ add_action( 'init', array( $this, 'init' ), 5 );
+ }
+
+ /**
+ * Initialize GDPR functionalities.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ public function init() {
+ $this->maybe_install_db();
+
+ $this->legal_consent = new LegalConsent();
+ $this->user_content = new UserConsent();
+ }
+
+ /**
+ * Create/update GDPR DB tables when needed.
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ private function maybe_install_db() {
+ $installed_version = get_option( self::DB_SCHEMA_VERSION_OPTION );
+ if ( self::DB_SCHEMA_VERSION === $installed_version ) {
+ return;
+ }
+
+ // Only create tables when WordPress is fully installed.
+ if ( ! function_exists( 'dbDelta' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ }
+
+ DB::create_tables();
+ update_option( self::DB_SCHEMA_VERSION_OPTION, self::DB_SCHEMA_VERSION, false );
+ }
+}
--- a/tutor/GDPR/Models/LegalConsentLogs.php
+++ b/tutor/GDPR/Models/LegalConsentLogs.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Compliance logs model.
+ *
+ * @package TutorGDPRModels
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRModels;
+
+use TutorGDPRDBLogs as Table;
+use TutorModelsBaseModel;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Compliance logs model class.
+ *
+ * @since 4.0.0
+ */
+class LegalConsentLogs extends BaseModel {
+
+ /**
+ * Table name without prefix.
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ protected $table_name = 'tutor_legal_consent_logs';
+
+ /**
+ * Fillable fields for create/update.
+ *
+ * @since 4.0.0
+ *
+ * @var array
+ */
+ protected $fillable = array(
+ 'id',
+ 'legal_consent_id',
+ 'action',
+ 'old_data',
+ 'new_data',
+ 'created_at_gmt',
+ );
+
+ /**
+ * Constructor.
+ *
+ * @since 4.0.0
+ */
+ public function __construct() {
+ $this->table_name = Table::get_table_name();
+ parent::__construct();
+ }
+}
--- a/tutor/GDPR/Models/LegalConsents.php
+++ b/tutor/GDPR/Models/LegalConsents.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Legal consent model.
+ *
+ * @package TutorGDPRModels
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRModels;
+
+use TutorGDPRDBLegalConsents as Table;
+use TutorModelsBaseModel;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Legal consent model class.
+ *
+ * @since 4.0.0
+ */
+class LegalConsents extends BaseModel {
+
+ /**
+ * Table name without prefix.
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ protected $table_name = 'tutor_legal_consents';
+
+ /**
+ * Fillable fields for create/update.
+ *
+ * @since 4.0.0
+ *
+ * @var array
+ */
+ protected $fillable = array(
+ 'consent_title',
+ 'display_on',
+ 'consent_message',
+ 'consent_map',
+ 'version',
+ 'consent_method',
+ 'is_active',
+ 'settings',
+ 'created_at_gmt',
+ 'updated_at_gmt',
+ );
+
+ /**
+ * Constructor.
+ *
+ * @since 4.0.0
+ */
+ public function __construct() {
+ $this->table_name = Table::get_table_name();
+ parent::__construct();
+ }
+
+ /**
+ * Get fillable fields
+ *
+ * @since 4.0.0
+ *
+ * @return array
+ */
+ public function get_fillable_fields() {
+ return $this->fillable;
+ }
+
+ /**
+ * Retrieve legal consent entries filtered by display key.
+ *
+ * @since 4.0.0
+ *
+ * @param string $display_key The display key to filter consents (e.g. 'login', 'signup', etc.).
+ *
+ * @return array An array of legal consent objects or an empty array if none found.
+ */
+ public function get_consents_by_display_key( string $display_key ): array {
+ global $wpdb;
+
+ $res = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT
+ *
+ FROM {$this->table_name}
+ WHERE FIND_IN_SET( %s, display_on )
+ ",
+ $display_key
+ )
+ );
+
+ return is_array( $res ) ? $res : array();
+ }
+}
--- a/tutor/GDPR/Models/UserConsents.php
+++ b/tutor/GDPR/Models/UserConsents.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * User content model.
+ *
+ * @package TutorGDPRModels
+ * @author Themeum <support@themeum.com>
+ * @link https://themeum.com
+ * @since 4.0.0
+ */
+
+namespace TutorGDPRModels;
+
+use TutorGDPRDBUserConsents as Table;
+use TUTORInput;
+use TutorModelsBaseModel;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * User content model class.
+ *
+ * @since 4.0.0
+ */
+class UserConsents extends BaseModel {
+
+ /**
+ * Table name without prefix.
+ *
+ * @since 4.0.0
+ *
+ * @var string
+ */
+ protected $table_name = 'tutor_user_contents';
+
+ /**
+ * Fillable fields for create/update.
+ *
+ * @since 4.0.0
+ *
+ * @var array
+ */
+ protected $fillable = array(
+ 'user_id',
+ 'user_email',
+ 'consent_title',
+ 'label_snapshot',
+ 'label_snapshot_plain_text',
+ 'links_snapshot',
+ 'consent_method',
+ 'version',
+ 'ip_address',
+ 'user_agent',
+ 'source',
+ 'created_at_gmt',
+ );
+
+ /**
+ * Constructor.
+ *
+ * @since 4.0.0
+ */
+ public function __construct() {
+ $this->table_name = Table::get_table_name();
+ parent::__construct();
+ }
+}
--- a/tutor/classes/Addons.php
+++ b/tutor/classes/Addons.php
@@ -10,9 +10,8 @@
namespace TUTOR;
-if ( ! defined( 'ABSPATH' ) ) {
- exit;
-}
+defined( 'ABSPATH' ) || exit;
+
/**
* Addons Class
*
@@ -25,6 +24,7 @@
* Constructor
*
* @since 1.0.0
+ *
* @return void
*/
public function __construct() {
@@ -52,6 +52,7 @@
*
* @param string $basename basename of addon.
* @param bool $status status 0,1.
+ *
* @return void
*/
public static function update_addon_status( $basename, $status ) {
@@ -66,6 +67,7 @@
* Get all addons data.
*
* @since 1.0.0
+ *
* @return void
*/
public function get_all_addons() {
@@ -73,7 +75,7 @@
// Check and verify the request.
tutor_utils()->checking_nonce();
- if ( ! User::is_admin() ) {
+ if ( ! User::can() ) {
wp_send_json_error( tutor_utils()->error_message() );
}
@@ -179,8 +181,8 @@
tutor_utils()->checking_nonce();
- if ( ! current_user_can( 'manage_options' ) ) {
- wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
+ if ( ! User::can() ) {
+ wp_send_json_error( tutor_utils()->error_message() );
}
$form_data = json_decode( Input::post( 'addonFieldNames' ) );
@@ -227,6 +229,7 @@
* Get tutor addons list
*
* @since 1.0.0
+ *
* @return array
*/
public function addons_lists_to_show() {
--- a/tutor/classes/Admin.php
+++ b/tutor/classes/Admin.php
@@ -10,16 +10,14 @@
namespace TUTOR;
+defined( 'ABSPATH' ) || exit;
+
use TutorEcommerceOrderController;
use TutorHelpersHttpHelper;
use TUTORInput;
use TutorModelsCourseModel;
use TutorTraitsJsonResponse;
-if ( ! defined( 'ABSPATH' ) ) {
- exit;
-}
-
/**
* Admin Class
*
@@ -32,10 +30,10 @@
* Constructor
*
* @since 1.0.0
+ *
* @return void
*/
public function __construct() {
-
add_action( 'admin_notices', array( $this, 'show_unstable_version_admin_notice' ) );
add_action( 'admin_menu', array( $this, 'register_menu' ) );
@@ -44,6 +42,7 @@
add_filter( 'submenu_file', array( $this, 'submenu_file_active' ), 10, 2 );
add_action( 'admin_init', array( $this, 'filter_posts_for_instructors' ) );
+ add_action( 'admin_init', array( $this, 'redirect_to_welcome_page' ) );
add_action( 'load-post.php', array( $this, 'check_if_current_users_post' ) );
add_filter( 'plugin_action_links_' . plugin_basename( TUTOR_FILE ), array( $this, 'plugin_action_links' ) );
@@ -62,6 +61,116 @@
add_action( 'admin_bar_menu', array( $this, 'add_toolbar_items' ), 100 );
add_action( 'wp_ajax_tutor_do_not_show_feature_page', array( $this, 'handle_do_not_show_feature_page' ) );
+
+ add_action( 'upgrader_process_complete', array( $this, 'set_permalink_flag_on_upgrade' ), 10, 2 );
+
+ add_action( 'admin_notices', array( $this, 'show_offer_notice' ) );
+ add_action( 'wp_ajax_tutor_dismiss_offer_notice', array( $this, 'ajax_dismiss_offer_notice' ) );
+ }
+
+ /**
+ * Flush Tutor permalink rewrite rules after updates.
+ *
+ * @since 4.0.0
+ *
+ * @param mixed $upgrader_object Upgrader instance.
+ * @param array $options Extra arguments passed to the hook.
+ *
+ * @return void
+ */
+ public function set_permalink_flag_on_upgrade( $upgrader_object, $options ) {
+ Permalink::set_permalink_reset_flag( $upgrader_object, $options );
+ }
+
+ /**
+ * Check offer notice is dismissed.
+ *
+ * @since 4.0.0
+ *
+ * @return bool
+ */
+ private function is_offer_notice_dismissed() {
+ return '4.0.0' === get_transient( 'tutor_offer_notice_dismissed' );
+ }
+
+ /**
+ * Show offer notice
+ *
+ * @since 4.0.0
+ *
+ * @return void
+ */
+ public function show_offer_notice() {
+ if ( ! User::is_admin() || Input::has( 'welcome', Input::GET_REQUEST ) ) {
+ return;
+ }
+
+ $is_free_user = ! tutor()->has_pro;
+ $cta_label = __( 'Claim 30% OFF', 'tutor' );
+ $cta_link = 'https://tutorlms.com/pricing/?utm_source=tutor-plugin&utm_medium=notice&utm_campaign=tutor-lms-pro-offer';
+
+ $now = new DateTimeImmutable( 'now', wp_timezone() );
+ $expiration_dt = '2026-07-15 23:59:59';
+ $expiration = new DateTimeImmutable( $expiration_dt, wp_timezone() );
+
+ $remaining = max( 0, $expiration->getTimestamp() - $now->getTimestamp() );
+
+ $days = floor( $remaining / DAY_IN_SECONDS );
+ $hours = floor( ( $remaining % DAY_IN_SECONDS ) / HOUR_IN_SECONDS );
+ $minutes = floor( ( $remaining % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );
+ $seconds = $remaining % MINUTE_IN_SECONDS;
+
+ $expire_in = sprintf(
+ '%dd:%02dh:%02d:%02d',
+ $days,
+ $hours,
+ $minutes,
+ $seconds
+ );
+
+ if ( $is_free_user && $remaining > 0 && ! self::is_offer_notice_dismissed() ) {
+ ?>
+ <div class="tutor-offer-notice">
+ <div class="tutor-offer-notice-wrapper">
+ <div class="tutor-offer-notice-left">
+ <div data-subheader><?php esc_html_e( '4.0 launch offer', 'tutor' ); ?></div>
+ <div data-header><?php esc_html_e( '30% OFF', 'tutor' ); ?></div>
+ <div data-subtext><?php esc_html_e( 'on all annual plans', 'tutor' ); ?></div>
+ </div>
+ <div class="tutor-offer-notice-right">
+ <div class="tutor-offer-notice-text"><?php esc_html_e( 'Offer ends by', 'tutor' ); ?> <span class="tutor-offer-notice-timer" data-expiry="<?php echo esc_attr( $expiration->getTimestamp() ); ?>"><?php echo esc_html( $expire_in ); ?></span></div>
+ <a class="tutor-offer-notice-cta" href="<?php echo esc_url( $cta_link ); ?>" target="_blank">
+ <?php echo esc_html( $cta_label ); ?>
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M13.938 6.937a.55.55 0 0 0-.786 0 .555.555 0 0 0 0 .778l3.338 3.34H5.243a.55.55 0 0 0-.55.551c0 .305.242.558.55.558h11.246l-3.337 3.334a.563.563 0 0 0 0 .785.55.55 0 0 0 .786 0L18.217 12a.543.543 0 0 0 0-.78z"/></svg>
+ </a>
+ </div>
+ </div>
+ <button type="button" class="tutor-offer-notice-dismiss"><span class="tutor-icon-times"></span></button>
+ </div>
+ <?php
+ }
+ }
+
+ /**
+ * Dismiss offer notice.
+ *
+ * @since 4.0.0
+ *
+ * @return void JSON response.
+ */
+ public function ajax_dismiss_offer_notice() {
+ if ( ! User::is_admin() ) {
+ $this->response_bad_request( tutor_utils()->error_message() );
+ }
+
+ if ( self::is_offer_notice_dismissed() ) {
+ $this->response_bad_request( __( 'You have already dismissed the offer notice.', 'tutor' ) );
+ }
+
+ // Set expiry until next day.
+ $expiration = strtotime( 'tomorrow' ) - time();
+ set_transient( 'tutor_offer_notice_dismissed', '4.0.0', $expiration );
+ $this->json_response( __( 'Notice dismissed', 'tutor' ) );
}
/**
@@ -333,13 +442,13 @@
*/
public function feature_promotion_page() {
include tutor()->path . 'views/pages/welcome.php';
- // include tutor()->path . 'views/pages/feature-promotion.php';
}
/**
* Show students page
*
* @since 1.0.0
+ *
* @return void
*/
public function tutor_students() {
@@ -350,6 +459,7 @@
* Show instructor page
*
* @since 1.0.0
+ *
* @return void
*/
public function tutor_instructors() {
@@ -360,6 +470,7 @@
* Show announcements page
*
* @since 1.0.0
+ *
* @return void
*/
public function tutor_announcements() {
@@ -370,6 +481,7 @@
* Show Q&A page
*
* @since 1.0.0
+ *
* @return void
*/
public function question_answer() {
@@ -380,6 +492,7 @@
* Show quiz attempts page
*
* @since 1.0.0
+ *
* @return void
*/
public function quiz_attempts() {
@@ -390,6 +503,7 @@
* Show the withdraw requests table
*
* @since 1.2.0
+ *
* @return void
*/
public function withdraw_requests() {
@@ -400,6 +514,7 @@
* Enable or disable addons
*
* @since 1.0.0
+ *
* @return void
*/
public function enable_disable_addons() {
@@ -410,6 +525,7 @@
* Tutor tools page (OLD)
*
* @since 1.0.0
+ *
* @return void
*/
public function tutor_tools_old() {
@@ -444,6 +560,7 @@
* Show pro upgrade page
*
* @since 1.0.0
+ *
* @return void
*/
public function tutor_get_pro() {
@@ -456,6 +573,7 @@
* @since 1.0.0
*
* @param string $parent_file parent file.
+ *
* @return string
*/
public function parent_menu_active( $parent_file ) {
@@ -495,6 +613,7 @@
* Filter posts for instructor
*
* @since 1.0.0
+ *
* @return void
*/
public function filter_posts_for_instructors() {
@@ -510,6 +629,7 @@
* @since 1.0.0
*
* @param mixed $clauses clauses.
+ *
* @return mixed
*/
public function posts_clauses_request( $clauses ) {
@@ -541,6 +661,7 @@
* Prevent unauthorised course/lesson edit page by direct URL
*
* @since 1.0.0
+ *
* @return void
*/
public function check_if_current_users_post() {
@@ -581,6 +702,7 @@
* @since 1.0.0
*
* @param string $template_path template file path.
+ *
* @return array