Source: includes/class-sensei-guest-user.php

<?php
/**
 * Guest User
 *
 * Handles operations related to allowing guest users take a course.
 *
 * @package Sensei\Frontend
 * @since 1.3.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Sensei Guest User Class.
 *
 * @author Automattic
 *
 * @since 4.11.0
 * @package Core
 */
class Sensei_Guest_User {

	/**
	 * Name of the Role for Guest Users.
	 *
	 * @since 4.11.0
	 *
	 * @var string
	 */
	const ROLE = 'guest_student';

	/**
	 * Guest user login name prefix.
	 *
	 * @since 4.11.0
	 *
	 * @var string
	 */
	const LOGIN_PREFIX = 'sensei_guest_';

	/**
	 * Email domain used for guest users.
	 *
	 * @since 4.14.0
	 *
	 * @var string
	 */
	const EMAIL_DOMAIN = 'guest.senseilms';

	/**
	 * Guest user id.
	 *
	 * @since 4.11.0
	 *
	 * @var int
	 */
	private $guest_user_id = 0;

	/**
	 * Meta key for course open access setting.
	 *
	 * @since 4.11.0
	 *
	 * @var string
	 */
	const COURSE_OPEN_ACCESS_META = '_open_access';

	/**
	 * List of actions to create a guest user for if the course is open access.
	 *
	 * @var array[] {
	 * @type string $field Form field.
	 * @type string $nonce Nonce field.
	 * @type bool   $enrol Whether to enrol the guest user before this action.
	 *                     }
	 */
	protected $supported_actions = [
		// Take course.
		[
			'field' => 'course_start',
			'nonce' => 'woothemes_sensei_start_course_noonce',
			'enrol' => false,
		],
		// Lesson complete.
		[
			'field' => 'quiz_action',
			'nonce' => 'woothemes_sensei_complete_lesson_noonce',
			'enrol' => true,
		],
		// Quiz complete.
		[
			'field' => 'quiz_complete',
			'nonce' => 'woothemes_sensei_complete_quiz_nonce',
			'enrol' => true,
		],
		// Quiz save.
		[
			'field' => 'quiz_save',
			'nonce' => 'woothemes_sensei_save_quiz_nonce',
			'enrol' => true,
		],
		// Quiz pagination. (Saves answers on the page).
		[
			'field' => 'quiz_target_page',
			'nonce' => 'sensei_quiz_page_change_nonce',
			'enrol' => true,
		],
	];

	/**
	 * Sensei_Guest_User constructor.
	 *
	 * @since 4.11.0
	 */
	public function __construct() {
		add_action( 'init', [ $this, 'log_guest_user_out_before_all_actions' ], 8 );

		add_action( 'wp', [ $this, 'init' ], 1 );

	}

	/**
	 * Initialize guest user feature.
	 *
	 * @since 4.11.0
	 */
	public function init() {
		/**
		 * Enable or disable 'open access course' feature.
		 *
		 * @hook  sensei_feature_open_access_courses
		 * @since 4.11.0
		 *
		 * @param {bool} $enable Enable feature. Default true.
		 *
		 * @return {bool} Wether to enable feature.
		 */
		if ( ! apply_filters( 'sensei_feature_open_access_courses', true ) ) {
			return;
		}

		add_action( 'wp', [ $this, 'log_in_guest_user_if_in_open_course' ], 8 );
		add_action( 'wp', [ $this, 'create_guest_user_and_login_for_open_course' ], 9 );
		add_action( 'sensei_is_enrolled', [ $this, 'open_course_always_enrolled' ], 10, 3 );
		add_action( 'sensei_can_access_course_content', [ $this, 'open_course_enable_course_access' ], 10, 2 );
		add_action( 'sensei_can_user_manually_enrol', [ $this, 'open_course_user_can_manualy_enroll' ], 10, 2 );
		add_filter( 'sensei_send_emails', [ $this, 'skip_sensei_email' ] );

		$this->create_guest_student_role_if_not_exists();

	}

	/**
	 * Log out the guest user before any action, some actions like Log in Form does not work if guest user is logged in
	 * even after setting current user to 0 by 'wp' hook.
	 *
	 * @since 4.11.0
	 */
	public function log_guest_user_out_before_all_actions() {
		if (
			is_user_logged_in() &&
			self::is_current_user_guest()
		) {
			$this->guest_user_id = get_current_user_id();
			wp_set_current_user( 0 );
		}
	}

	/**
	 * Filter enrolment check to always return true if the course is open access.
	 *
	 * @since  4.11.0
	 *
	 * @param bool $is_enrolled Initial value.
	 * @param int  $user_id User ID. Unused.
	 * @param int  $course_id Course ID.
	 *
	 * @return bool
	 */
	public function open_course_always_enrolled( $is_enrolled, $user_id, $course_id ) {
		$in_course_content = is_singular( [ 'lesson', 'quiz' ] );
		return ( $in_course_content && $this->is_course_open_access( $course_id ) ) ? true : $is_enrolled;
	}

	/**
	 * Filter manual enrolment check to always allow users to manually enrol if the course is open access.
	 *
	 * @since  4.11.0
	 *
	 * @param bool $can_enroll Initial value.
	 * @param int  $course_id Course ID.
	 *
	 * @return bool
	 */
	public function open_course_user_can_manualy_enroll( $can_enroll, $course_id ) {

		$is_user_enrolled = is_user_logged_in() && Sensei_Course::is_user_enrolled( $course_id, get_current_user_id() );
		return $this->is_course_open_access( $course_id ) ? ! $is_user_enrolled : $can_enroll;
	}

	/**
	 * Filter course access check to always return true if the course is open access.
	 *
	 * @since  4.11.0
	 *
	 * @param bool $can_view_course_content Initial value.
	 * @param int  $course_id Course ID.
	 *
	 * @return bool
	 */
	public function open_course_enable_course_access( $can_view_course_content, $course_id ) {
		return $this->is_course_open_access( $course_id ) ? true : $can_view_course_content;
	}

	/**
	 * Create a guest user for open access courses if no user is logged in.
	 *
	 * @since 4.11.0
	 * @access private
	 */
	public function create_guest_user_and_login_for_open_course() {

		global $post;
		$course_id = Sensei_Utils::get_current_course();

		if ( empty( $course_id ) || is_user_logged_in() || ! $this->is_course_open_access( $course_id ) || post_password_required( $post->ID ) ) {
			return;
		}

		$current_action = $this->get_current_action();

		// Conditionally create Guest Student user and set role for open course.
		if ( $current_action ) {
			$user_id = self::create_guest_user();
			$this->login_user( $user_id );
			$this->recreate_nonce( $current_action );

			if ( $current_action['enrol'] ) {
				$this->enrol_user( $user_id, $course_id );
			}
		}

	}

	/**
	 * Sets current guest user to none if out of open course context.
	 *
	 * @since 4.11.0
	 * @access private
	 */
	public function log_in_guest_user_if_in_open_course() {
		if (
			! is_user_logged_in() &&
			$this->is_open_course_related_action() &&
			$this->guest_user_id > 0
		) {
			wp_set_current_user( $this->guest_user_id );
		}
	}

	/**
	 * Checks if the action is related to an open course or a lesson or a quiz that belongs to an open course.
	 *
	 * @since 4.11.0
	 * @access private
	 * @return boolean
	 */
	private function is_open_course_related_action() {
		if ( ! is_singular( [ 'course', 'lesson', 'quiz' ] ) ) {
			return false;
		}

		return $this->is_course_open_access( Sensei_Utils::get_current_course() );
	}

	/**
	 * Check if the course is open access.
	 *
	 * @param int $course_id ID of the course.
	 *
	 * @since  4.11.0
	 * @return boolean|mixed
	 */
	private function is_course_open_access( $course_id ) {
		$is_open_access = get_post_meta( $course_id, self::COURSE_OPEN_ACCESS_META, true );

		/**
		 * Filter if the given course has open access turned on.
		 *
		 * @hook  sensei_course_open_access
		 * @since 4.11.0
		 *
		 * @param {bool} $is_open_access Open access setting value.
		 * @param {int} $course_id Course ID.
		 *
		 * @return {bool} Open access setting value.
		 */
		return apply_filters( 'sensei_course_open_access', $is_open_access, $course_id );
	}

	/**
	 * Checks if the current user is a guest.
	 *
	 * @since 4.11.0
	 * @access private
	 */
	private static function is_current_user_guest() {
		$user = wp_get_current_user();
		return self::is_guest_user( $user );
	}

	/**
	 * Recreate nonce after logging in user invalidates existing one.
	 *
	 * @since 4.11.0
	 *
	 * @param array $action Action to recreate nonce for.
	 */
	private function recreate_nonce( $action ) {
		$nonce           = $action['nonce'];
		$_POST[ $nonce ] = wp_create_nonce( $nonce );
	}

	/**
	 * Create a user with Guest Student role .
	 *
	 * @since  4.11.0
	 * @return int
	 */
	public static function create_guest_user() {
		$user_count = Sensei_Utils::get_user_count_for_role( self::ROLE ) + 1;
		$user_name  = self::LOGIN_PREFIX . wp_rand( 10000000, 99999999 ) . '_' . $user_count;
		return Sensei_Temporary_User::create_user(
			[
				'user_pass'    => wp_generate_password(),
				'user_login'   => $user_name,
				'user_email'   => $user_name . '@' . self::EMAIL_DOMAIN,
				'display_name' => 'Guest Student ' . str_pad( $user_count, 3, '0', STR_PAD_LEFT ),
				'role'         => self::ROLE,
			]
		);
	}

	/**
	 * Delete a guest user and remove their course progress.
	 *
	 * @param int $user_id User ID.
	 *
	 * @return void
	 */
	public static function delete_guest_user( $user_id ): void {

		if ( ! $user_id || ! self::is_guest_user( $user_id ) ) {
			return;
		}

		$course_ids = Sensei_Learner::instance()->get_enrolled_courses_query(
			$user_id,
			[
				'posts_per_page' => -1,
				'fields'         => 'ids',
			]
		)->posts;

		foreach ( $course_ids as $course_id ) {
			Sensei_Utils::sensei_remove_user_from_course( $course_id, $user_id );
		}

		Sensei_Temporary_User::delete_user( $user_id );
	}

	/**
	 * Log a user in.
	 *
	 * @param int $user_id ID of the user.
	 *
	 * @since 4.11.0
	 */
	private function login_user( $user_id ) {
		wp_set_current_user( $user_id );
		wp_set_auth_cookie( $user_id, true );
	}

	/**
	 * Manually enrol the new user in the course.
	 *
	 * @since 4.11.0
	 *
	 * @param int $user_id User ID.
	 * @param int $course_id Course ID.
	 */
	private function enrol_user( $user_id, $course_id ) {
		if ( ! Sensei_Course::can_current_user_manually_enrol( $course_id )
			|| ! Sensei_Course::is_prerequisite_complete( $course_id ) ) {
			return; // Error message?
		}

		$course_enrolment = Sensei_Course_Enrolment::get_course_instance( $course_id );
		$course_enrolment->enrol( $user_id );
	}

	/**
	 * Create the Guest Student role if it does not exist.
	 *
	 * @since 4.11.0
	 */
	private function create_guest_student_role_if_not_exists() {
		// Check if the Guest Student role exists.
		$guest_role = get_role( self::ROLE );

		// If Guest Student is not a valid WordPress role create it.
		if ( ! is_a( $guest_role, 'WP_Role' ) ) {
			// Create the role.
			add_role( self::ROLE, __( 'Guest Student', 'sensei-lms' ) );
		}
	}

	/**
	 * Determine if the current requests is for a supported action.
	 *
	 * @since 4.11.0
	 *
	 * @return string[]|null
	 */
	private function get_current_action() {

		/**
		 * Filters the list of supported actions for Guest Users.
		 *
		 * @since 4.11
		 *
		 * @hook sensei_guest_user_supported_actions
		 *
		 * @param {array} List of supported actions for guest users.
		 * @return {array} List of supported actions for guest users.
		 */
		$supported_actions = apply_filters( 'sensei_guest_user_supported_actions', $this->supported_actions );

		foreach ( $supported_actions as $action ) {
			if ( $this->is_action( $action['field'], $action['nonce'] ) ) {
				return $action;
			}
		}

		return null;
	}

	/**
	 * Determines if the request is for an action submitting the given form field and nonce.
	 *
	 * @since  4.11.0
	 *
	 * @param string $field Form field name for the action.
	 * @param string $nonce Nonce name for the action.
	 *
	 * @return boolean
	 */
	private function is_action( $field, $nonce ) {
		return isset( $_POST[ $field ] )
			&& isset( $_POST[ $nonce ] )
			&& wp_verify_nonce( wp_unslash( $_POST[ $nonce ] ), $nonce ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verification
	}

	/**
	 * Prevent Sensei emails related to guest user actions.
	 *
	 * @access private
	 * @since  4.11.0
	 *
	 * @param boolean $send_email Whether to send the email.
	 *
	 * @return boolean Whether to send the email.
	 */
	public function skip_sensei_email( $send_email ) {
		return self::is_current_user_guest() ? false : $send_email;
	}

	/**
	 * Prevent emails related to the guest user from being dispatched via wp_mail.
	 *
	 * @access private
	 * @since  4.14.0
	 *
	 * @param bool|null $return Null if we should send the email, a boolean if not.
	 * @param array     $atts   Email attributes.
	 * @return bool|null Null if we should send the email, a boolean if not.
	 */
	public static function skip_wp_mail( $return, $atts ) {
		if ( self::is_current_user_guest() ) {
			// If this e-mail is being dispatched while the current user is a guest, just... don't send it.
			return false;
		}
		if ( Sensei_Temporary_User::should_block_email( $atts, self::EMAIL_DOMAIN ) ) {
			// If this e-mail is being dispatched to a guest user, don't send it.
			return false;
		}
		return $return;
	}

	/**
	 * Check if the given user is a guest user.
	 *
	 * @param WP_User|int $user User object or ID.
	 *
	 * @return bool
	 */
	private static function is_guest_user( $user ): bool {
		if ( is_numeric( $user ) ) {
			$user = get_user_by( 'ID', $user );
		}
		return in_array( self::ROLE, (array) $user->roles, true );
	}
}