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

<?php
/**
 * Preview User
 *
 * Handles operations related to teachers switching to a preview user.
 *
 * @package Sensei\Frontend
 * @since   4.11.0
 */

/**
 * Sensei Preview User Class.
 *
 * @author  Automattic
 *
 * @since   4.11.0
 * @package Core
 */
class Sensei_Preview_User {

	/**
	 * Preview user role.
	 */
	const ROLE = 'preview_student';

	/**
	 * Switch to/from preview user actions.
	 */
	const SWITCH_ON_ACTION  = 'sensei-preview-as-student';
	const SWITCH_OFF_ACTION = 'sensei-exit-student-preview';

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

	/**
	 * Meta key for the associated preview user ID.
	 * Used to link the original teacher and the preview user, in both directions.
	 */
	const META = 'sensei_previewing_user';

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

	/**
	 * Preview user class constructor.
	 *
	 * @since 4.11.0
	 */
	public function __construct() {

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

	}

	/**
	 * Initialize preview user feature.
	 *
	 * @since 4.11.0
	 */
	public function init() {

		/**
		 * Enable or disable 'preview as student' feature.
		 *
		 * @since 4.11.0
		 *
		 * @hook sensei_feature_preview_students
		 *
		 * @param {bool} $enable Enable feature. Default true.
		 * @return {bool} Wether to enable feature.
		 */
		if ( ! apply_filters( 'sensei_feature_preview_students', true ) ) {
			return;
		}

		add_action( 'wp', [ $this, 'switch_to_preview_user' ], 9 );
		add_action( 'wp', [ $this, 'switch_off_preview_user' ], 9 );
		add_action( 'wp', [ $this, 'override_user' ], 8 );
		add_action( 'wp', [ $this, 'add_preview_user_filters' ], 9 );
		add_action( 'show_admin_bar', [ $this, 'show_admin_bar_to_preview_user' ], 90 );
		add_action( 'admin_bar_menu', [ $this, 'add_user_switch_to_admin_bar' ], 90 );
		add_filter( 'sensei_is_enrolled', [ $this, 'preview_user_always_enrolled' ], 90, 3 );

		$this->create_role();
	}

	/**
	 * Activate filters used when a preview user is active.
	 *
	 * @access private
	 */
	public function add_preview_user_filters() {
		if ( self::is_preview_user_active() ) {
			add_filter( 'map_meta_cap', [ $this, 'allow_post_preview' ], 10, 4 );
			add_filter( 'pre_get_posts', [ $this, 'count_unpublished_lessons' ], 10 );
			add_filter( 'sensei_notice', [ $this, 'hide_notices' ], 10, 1 );
			add_filter( 'sensei_send_emails', '__return_false' );

		}

	}

	/**
	 * Change the current user to the preview user if its set for the teacher.
	 *
	 * @since 4.11.0
	 * @access private
	 */
	public function override_user() {

		$course_id = Sensei_Utils::get_current_course();
		if ( ! $course_id ) {
			return;
		}

		$preview_user = $this->get_preview_user( get_current_user_id(), $course_id );

		if ( ! $preview_user ) {
			return;
		}

		// Clear out meta for the teacher if the preview user doesn't exist.
		if ( ! self::is_preview_user( $preview_user ) ) {
			self::delete_meta( get_current_user_id(), $preview_user, $course_id );
			return;
		}

		wp_set_current_user( $preview_user );
	}

	/**
	 * Create and switch to a preview user.
	 *
	 * @since 4.11.0
	 * @access private
	 */
	public function switch_to_preview_user() {

		$course_id = Sensei_Utils::get_current_course();

		if ( ! $course_id || ! $this->is_action( self::SWITCH_ON_ACTION ) || ! $this->can_switch_to_preview_user( $course_id ) ) {
			return;
		}

		$preview_user_id = $this->create_preview_user( $course_id );
		$this->set_preview_user( $preview_user_id );

		wp_safe_redirect( remove_query_arg( self::SWITCH_ON_ACTION ) );

	}

	/**
	 * Switch back to original user and delete preview user.
	 *
	 * @since 4.11.0
	 * @access private
	 */
	public function switch_off_preview_user() {

		if ( ! $this->is_action( self::SWITCH_OFF_ACTION ) ) {
			return;
		}

		self::delete_preview_user( get_current_user_id() );

		wp_safe_redirect( remove_query_arg( self::SWITCH_OFF_ACTION ) );

	}

	/**
	 * Add switch to user link to admin bar.
	 *
	 * @since 4.11.0
	 * @access private
	 *
	 * @param WP_Admin_Bar $wp_admin_bar The WordPress Admin Bar object.
	 */
	public function add_user_switch_to_admin_bar( $wp_admin_bar ) {

		$course_id = Sensei_Utils::get_current_course();
		if ( ! $course_id ) {
			return;
		}

		if ( $this->can_switch_to_preview_user( $course_id ) && ! self::is_preview_user_active() ) {
			$wp_admin_bar->add_node(
				[
					'id'     => self::SWITCH_ON_ACTION,
					'title'  => __( 'Preview as Student', 'sensei-lms' ),
					'parent' => 'top-secondary',
					'href'   => add_query_arg( [ self::SWITCH_ON_ACTION => wp_create_nonce( self::SWITCH_ON_ACTION ) ] ),
					'meta'   => [
						'class' => 'sensei-user-switch-preview',
					],
				]
			);
		}

		if ( self::is_preview_user_active() ) {
			$wp_admin_bar->add_node(
				[
					'id'     => self::SWITCH_OFF_ACTION,
					'title'  => __( 'Exit Student Preview', 'sensei-lms' ),
					'parent' => 'top-secondary',
					'href'   => add_query_arg( [ self::SWITCH_OFF_ACTION => wp_create_nonce( self::SWITCH_OFF_ACTION ) ] ),
					'meta'   => [
						'class' => 'sensei-user-switch-preview',
					],
				]
			);
		}

	}

	/**
	 * Enable admin bar for preview user.
	 *
	 * @since 4.11.0
	 * @access private
	 *
	 * @param bool $show Initial state.
	 *
	 * @return bool
	 */
	public function show_admin_bar_to_preview_user( $show ) {
		if ( self::is_preview_user_active() ) {
			return true;
		}

		return $show;
	}

	/**
	 * Check if the current user can switch to a preview student for the course.
	 *
	 * @param int $course_id Course ID.
	 *
	 * @return bool
	 */
	private function can_switch_to_preview_user( $course_id ) {
		return Sensei_Course::can_current_user_edit_course( $course_id );
	}

	/**
	 * Prevent emails related to the preview 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_preview_user_active() ) {
			// If this e-mail is being dispatched while the current user is a previwe user, 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 preview user, don't send it.
			return false;
		}
		return $return;
	}

	/**
	 * Check if the request is for the given action.
	 *
	 * @param string $action Action field and nonce name.
	 *
	 * @return bool
	 */
	private function is_action( $action ) {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verification
		return isset( $_GET[ $action ] ) && wp_verify_nonce( wp_unslash( $_GET[ $action ] ), $action );
	}

	/**
	 * Create a preview user for the current teacher.
	 *
	 * @param int $course_id Course ID.
	 *
	 * @return int
	 */
	private function create_preview_user( $course_id ) {
		$teacher      = wp_get_current_user();
		$user_name    = self::LOGIN_PREFIX . wp_rand( 10000000, 99999999 ) . '_' . $teacher->ID . '_' . $course_id;
		$display_name = 'Preview Student ' . $course_id . '-' . $teacher->ID . ' (' . $teacher->display_name . ')';

		return Sensei_Temporary_User::create_user(
			[
				'user_pass'    => wp_generate_password(),
				'user_login'   => $user_name,
				'user_email'   => $user_name . '@' . self::EMAIL_DOMAIN,
				'display_name' => $display_name,
				'last_name'    => $display_name,
				'role'         => self::ROLE,
				'meta_input'   => [
					self::META => self::meta_value( $teacher->ID, $course_id ),
				],
			]
		);
	}


	/**
	 * Delete preview user, including their course progress data.
	 *
	 * @param int $user_id User ID for the preview user.
	 */
	public static function delete_preview_user( $user_id ) {

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

		list( 'user' => $teacher, 'course' => $course_id ) = get_user_meta( $user_id, self::META, true );

		self::delete_meta( $teacher, $user_id, $course_id );

		Sensei_Temporary_User::delete_user( $user_id );
	}

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

		if ( ! is_a( $role, 'WP_Role' ) ) {
			add_role( self::ROLE, __( 'Preview Student', 'sensei-lms' ) );
		}
	}

	/**
	 * Allow preview user to view draft posts.
	 *
	 * This effectively allows them the 'edit_post' and 'read_private_posts' caps, but this filter will only run on course frontend pages.
	 *
	 * @note This hook should only run when the preview user is active, it does not do checks on its own.
	 *
	 * @access private
	 *
	 * @param array  $caps    Capabilities.
	 * @param string $cap     Capability.
	 * @param int    $user_id User ID.
	 * @param array  $args    Arguments.
	 *
	 * @return array
	 */
	public function allow_post_preview( $caps, $cap, $user_id, $args ) {

		if ( get_current_user_id() !== $user_id ) {
			return $caps;
		}

		if ( in_array( $cap, [ 'edit_post', 'read_private_posts' ], true ) ) {
			return [];
		}

		return $caps;
	}

	/**
	 * Hide draft course notices.
	 *
	 * @note This hook should only run when the preview user is active, it does not do checks on its own.
	 *
	 * @access private
	 *
	 * @param array $notice Notice.
	 *
	 * @return array|false
	 */
	public function hide_notices( $notice ) {

		if ( in_array( $notice['key'], [ 'sensei-course-outline-drafts' ], true ) ) {
			return false;
		}
		return $notice;
	}

	/**
	 * Change lesson queries to include unpublished lessons.
	 *
	 * Needed for course progress calculation (Sensei_Course::get_progress_stats).
	 *
	 * @note This hook should only run when the preview user is active, it does not do checks on its own.
	 *
	 * @since 4.11.0
	 * @access private
	 *
	 * @param WP_Query $query Lesson query.
	 *
	 * @return void
	 */
	public function count_unpublished_lessons( WP_Query $query ) {
		if ( $query->get( 'post_type' ) === 'lesson' ) {
			$query->set( 'post_status', [ 'any' ] );
		}
	}

	/**
	 * Always treat preview user as enrolled in the course.
	 *
	 * @access private
	 *
	 * @param bool $is_enrolled Initial state.
	 * @param int  $user_id     User ID.
	 * @param int  $course_id   Course ID.
	 *
	 * @return bool
	 */
	public function preview_user_always_enrolled( $is_enrolled, $user_id, $course_id ) {

		if ( ! self::is_preview_user( $user_id ) ) {
			return $is_enrolled;
		}
		list( 'course' => $preview_course_id ) = get_user_meta( $user_id, self::META, true );
		if ( (int) $course_id === $preview_course_id ) {
			return true;
		}
		return $is_enrolled;
	}

	/**
	 * Get preview user for the teacher and course if one is active.
	 *
	 * @param int $user_id   Teacher user ID.
	 * @param int $course_id Course ID.
	 *
	 * @return false|int
	 */
	private function get_preview_user( $user_id, $course_id ) {
		$preview_users = get_user_meta( $user_id, self::META, false );
		if ( empty( $preview_users ) || ! $course_id ) {
			return false;
		}
		foreach ( $preview_users as $preview_user ) {
			if ( $preview_user['course'] === $course_id ) {
				return $preview_user['user'];
			}
		}
		return false;
	}

	/**
	 * Store preview user for the current teacher as user meta.
	 *
	 * @param int $preview_user_id Preview user ID.
	 */
	private function set_preview_user( $preview_user_id ) {
		list( 'course' => $course_id ) = get_user_meta( $preview_user_id, self::META, true );
		$user_id                       = get_current_user_id();
		$existing_preview_user         = $this->get_preview_user( $user_id, $course_id );

		if ( $existing_preview_user ) {
			self::delete_preview_user( $existing_preview_user );
		}

		$this->add_meta( $user_id, $preview_user_id, $course_id );
	}

	/**
	 * Check if the current user is a preview user.
	 *
	 * @return bool
	 */
	private static function is_preview_user_active() {
		$user = wp_get_current_user();
		return self::is_preview_user( $user );
	}

	/**
	 * Set preview user meta for the teacher.
	 *
	 * @param int $teacher_user_id Teacher user ID.
	 * @param int $preview_user_id Preview user ID.
	 * @param int $course_id       Course ID.
	 *
	 * @return false|int
	 */
	private function add_meta( $teacher_user_id, $preview_user_id, $course_id ) {
		return add_user_meta(
			$teacher_user_id,
			self::META,
			self::meta_value( $preview_user_id, $course_id )
		);
	}

	/**
	 * Delete preview user meta for the teacher.
	 *
	 * @param int $teacher_user_id Teacher user ID.
	 * @param int $preview_user_id Preview user ID.
	 * @param int $course_id Course ID.
	 *
	 * @return void
	 */
	private static function delete_meta( $teacher_user_id, int $preview_user_id, $course_id ): void {
		delete_user_meta(
			$teacher_user_id,
			self::META,
			self::meta_value( $preview_user_id, $course_id )
		);

		Sensei_Utils::sensei_remove_user_from_course( $course_id, $preview_user_id );
	}

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

	/**
	 * Format meta value.
	 *
	 * @param int $user_id   User ID.
	 * @param int $course_id Course ID.
	 *
	 * @return array
	 */
	private static function meta_value( $user_id, $course_id ) {
		return [
			'user'   => absint( $user_id ),
			'course' => absint( $course_id ),
		];
	}

}