Source: includes/admin/class-sensei-admin-notices.php

<?php
/**
 * File containing the class Sensei_Admin_Notices.
 *
 * @package sensei-lms
 * @since   3.11.1
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class that handles showing admin notices.
 * It also includes notices coming from SenseiLMS.com.
 *
 * @access private
 *
 * @class Sensei_Admin_Notices
 */
class Sensei_Admin_Notices {
	const SENSEILMS_NOTICES_API_URL      = 'https://senseilms.com/wp-json/senseilms-notices/1.0/notices';
	const DISMISS_NOTICE_NONCE_ACTION    = 'sensei-lms-dismiss-notice';
	const DISMISSED_NOTICES_OPTION       = 'sensei-dismissed-notices';
	const DISMISSED_NOTICES_USER_META    = 'sensei-dismissed-notices';
	const ALL_SENSEI_SCREENS_PLACEHOLDER = 'sensei*';

	const ALLOWED_HTML = array(
		'strong' => array(),
		'em'     => array(),
		'a'      => array(
			'target' => array(),
			'href'   => array(),
			'rel'    => array(),
		),
	);

	const ALLOWED_CAP_CHECKS = array(
		'activate_plugins',
		'install_plugins',
		'manage_options',
		'update_core',
		'update_plugins',
		'delete_plugins',
		'edit_posts',
		'edit_others_posts',
	);

	const SENSEI_SCREEN_IDS = array(
		'edit-course',
		'edit-lesson',
		'edit-question',
		'edit-sensei_message',
		'edit-module',
		'edit-course-category',
		'edit-question-type',
		'edit-question-category',
		'edit-lesson-tag',
		Sensei_Home::SCREEN_ID,
		'sensei-lms_page_' . Sensei_Analysis::PAGE_SLUG,
		'sensei-lms_page_sensei_learners',
		'sensei-lms_page_sensei-settings',
		'sensei-lms_page_sensei_grading',
		'sensei-lms_page_sensei-tools',
		'admin_page_lesson-order',
	);

	const OTHER_ALLOWED_SCREEN_IDS = array(
		'dashboard',
		'update-core',
		'themes',
		'edit-page',
		'edit-post',
		'edit-product',
		'plugins',
		'plugins-network',
		'woocommerce_page_wc-admin',
		'woocommerce_page_wc-addons',
	);

	/**
	 * Instance of class.
	 *
	 * @var self
	 */
	private static $instance;

	/**
	 * Fetches an instance of the class.
	 *
	 * @return self
	 */
	public static function instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Class constructor.
	 */
	private function __construct() {
		// Silence is golden.
	}

	/**
	 * Initialize hooks.
	 */
	public function init() {
		if ( ! is_admin() ) {
			return;
		}

		add_filter( 'admin_notices', array( $this, 'add_admin_notices' ) );
		add_action( 'wp_ajax_sensei_dismiss_notice', array( $this, 'handle_notice_dismiss' ) );
	}

	/**
	 * Get the screen ID.
	 *
	 * @return string|null
	 */
	protected function get_screen_id() {
		if ( ! function_exists( 'get_current_screen' ) ) {
			return null;
		}

		$screen = get_current_screen();

		return $screen ? $screen->id : null;
	}

	/**
	 * Get notices.
	 *
	 * @param int|null $max_age The max age (seconds) of the source data.
	 *
	 * @return array
	 */
	protected function get_notices( $max_age = null ) {
		$cur_time      = Sensei()->clock->now()->getTimestamp();
		$transient_key = implode( '_', array( 'sensei_notices', Sensei()->version, determine_locale() ) );
		$data          = get_transient( $transient_key );
		$notices       = false;
		// If the data is too old, fetch it again.
		if ( $max_age && is_array( $data ) ) {
			$age = $cur_time - ( $data['_fetched'] ?? 0 );
			if ( $age > $max_age ) {
				$data = false;
			}
		}

		if ( isset( $data['notices'] ) ) {
			$notices = $data['notices'];
		}

		if ( false === $notices ) {
			$notices_response = wp_safe_remote_get(
				add_query_arg(
					array(
						'version' => Sensei()->version,
						'lang'    => determine_locale(),
					),
					self::SENSEILMS_NOTICES_API_URL
				)
			);

			if ( ! is_wp_error( $notices_response ) && 200 === wp_remote_retrieve_response_code( $notices_response ) ) {
				$notices_response_body = json_decode( wp_remote_retrieve_body( $notices_response ), true );
				if ( $notices_response_body && isset( $notices_response_body['notices'] ) ) {
					$notices     = $notices_response_body['notices'];
					$cached_data = array(
						'_fetched' => $cur_time,
						'notices'  => $notices,
					);
					set_transient( $transient_key, $cached_data, DAY_IN_SECONDS );
				}
			}
		}

		if ( ! $notices || ! is_array( $notices ) ) {
			$notices = array();
		}

		/**
		 * Filters the admin notices.
		 *
		 * @hook sensei_admin_notices
		 *
		 * @param {array}    $notices The admin notices.
		 * @param {int|null} $max_age The max age (seconds) of the source data.
		 * @return {array} The admin notices.
		 */
		$notices = apply_filters( 'sensei_admin_notices', $notices, $max_age );

		return $notices;
	}

	/**
	 * Output the admin notice.
	 *
	 * @access private
	 */
	public function add_admin_notices() {
		$screen_id = $this->get_screen_id();

		/**
		 * Adds the ability to hide notices on a specific screen.
		 *
		 * @hook sensei_show_admin_notices_{$screen_id}
		 * @since 4.8.0
		 *
		 * @param {bool} $hide_notices_on_screen Whether to hide notices on the screen.
		 *
		 * @return {bool} Whether to hide notices on the screen.
		 */
		if ( ! apply_filters( "sensei_show_admin_notices_{$screen_id}", true ) ) {
			return;
		}

		foreach ( $this->get_notices_to_display() as $notice_id => $notice ) {
			$this->add_admin_notice( $notice_id, $notice );
		}
	}

	/**
	 * Output the admin notice.
	 *
	 * @param string $notice_id The unique notice ID.
	 * @param array  $notice The notice configuration.
	 */
	private function add_admin_notice( $notice_id, $notice ) {
		if ( empty( $notice['actions'] ) || ! is_array( $notice['actions'] ) ) {
			$notice['actions'] = array();
		}

		$notice_classes   = array();
		$notice_classes[] = 'sensei-notice--' . $notice['level'];

		$is_dismissible       = $notice['dismissible'];
		$notice_wrapper_extra = '';
		if ( $is_dismissible ) {
			wp_enqueue_script( 'sensei-dismiss-notices' );
			$notice_classes[]     = 'is-dismissible';
			$notice_wrapper_extra = sprintf( ' data-dismiss-action="sensei_dismiss_notice" data-dismiss-notice="%1$s" data-dismiss-nonce="%2$s"', esc_attr( $notice_id ), esc_attr( wp_create_nonce( self::DISMISS_NOTICE_NONCE_ACTION ) ) );
		}
		?>
		<div class="notice sensei-notice <?php echo esc_attr( implode( ' ', $notice_classes ) ); ?>"
			<?php
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above.
			echo $notice_wrapper_extra;
			?>
		>
			<?php
			echo '<div class="sensei-notice__content">';
			if ( ! empty( $notice['icon'] ) ) {
				// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- Dynamic parts escaped in the function.
				echo Sensei()->assets->get_icon( $notice['icon'], 'sensei-notice__icon' );
			}
			echo '<div>';
			if ( ! empty( $notice['heading'] ) ) {
				echo '<div class="sensei-notice__heading">';
				echo wp_kses( $notice['heading'], self::ALLOWED_HTML );
				echo '</div>';
			}
			echo wp_kses( $notice['message'], self::ALLOWED_HTML );
			echo '</div>';
			echo '</div>';
			if ( ! empty( $notice['actions'] ) ) {
				echo '<div class="sensei-notice__actions">';
				foreach ( $notice['actions'] as $action ) {
					if ( ! isset( $action['label'] ) || ( ! isset( $action['url'] ) && ! isset( $action['tasks'] ) ) ) {
						continue;
					}

					$button_class = ! isset( $action['primary'] ) || $action['primary'] ? 'button-primary' : 'button-secondary';
					$extra_attrs  = '';
					if ( isset( $action['tasks'] ) ) {
						wp_enqueue_script( 'sensei-dismiss-notices' );
						$extra_attrs = ' data-sensei-notice-tasks="' . esc_attr( wp_json_encode( $action['tasks'] ) ) . '"';
					}
					echo '<a href="' . esc_url( $action['url'] ) . '" target="' . esc_attr( $action['target'] ?? '_self' ) . '" rel="noopener noreferrer" class="button ' . esc_attr( $button_class ) . '"' . $extra_attrs . '>';
					echo esc_html( $action['label'] );
					echo '</a>';
				}
				echo '</div>';
			}
			?>
		</div>
		<?php
	}

	/**
	 * Get the notices to display to the user.
	 *
	 * @access private
	 *
	 * @param string   $screen_id The screen ID.
	 * @param int|null $max_age   The max age (seconds) of the source data.
	 *
	 * @return array
	 */
	public function get_notices_to_display( $screen_id = null, $max_age = null ) {
		$notices = array();
		foreach ( $this->get_notices( $max_age ) as $notice_id => $notice ) {
			$notice = $this->normalize_notice( $notice );

			$is_user_notification = 'user' === $notice['type'];

			if (
				! isset( $notice['message'] )
				|| ( $notice['dismissible'] && $this->is_notice_dismissed( $notice_id, $is_user_notification ) )
				|| ! $this->check_notice_conditions( $notice, $screen_id )
			) {
				continue;
			}

			$notices[ $notice_id ] = $notice;
		}

		return $notices;
	}

	/**
	 * Check notice conditions.
	 *
	 * @param array  $notice The notice configuration.
	 * @param string $screen_id The screen ID.
	 *
	 * @return bool
	 */
	private function check_notice_conditions( $notice, $screen_id = null ) {
		if ( ! isset( $notice['conditions'] ) || ! is_array( $notice['conditions'] ) ) {
			$notice['conditions'] = array();
		}

		$has_screen_condition = false;
		$can_see_notice       = true;

		foreach ( $notice['conditions'] as $condition ) {
			if ( ! isset( $condition['type'] ) ) {
				continue;
			}

			switch ( $condition['type'] ) {
				case 'min_php':
					if ( ! isset( $condition['version'] ) ) {
						break;
					}

					if ( ! $this->condition_check_min_php( $condition['version'] ) ) {
						$can_see_notice = false;
						break 2;
					}

					break;
				case 'min_wp':
					if ( ! isset( $condition['version'] ) ) {
						break;
					}

					if ( ! $this->condition_check_min_wp( $condition['version'] ) ) {
						$can_see_notice = false;
						break 2;
					}

					break;
				case 'user_cap':
					if ( ! isset( $condition['capabilities'] ) || ! is_array( $condition['capabilities'] ) ) {
						break;
					}

					if ( ! $this->condition_check_capabilities( $condition['capabilities'] ) ) {
						$can_see_notice = false;
						break 2;
					}

					break;
				case 'screens':
					if ( ! isset( $condition['screens'] ) || ! is_array( $condition['screens'] ) ) {
						break;
					}

					$has_screen_condition = true;
					if ( ! $this->condition_check_screen( $condition['screens'], $screen_id ) ) {
						$can_see_notice = false;
						break 2;
					}

					break;
				case 'plugins':
					if ( ! isset( $condition['plugins'] ) || ! is_array( $condition['plugins'] ) ) {
						break;
					}

					if ( ! $this->condition_check_plugin( $condition['plugins'] ) ) {
						$can_see_notice = false;
						break 2;
					}
					break;

				case 'installed_since':
					if ( ! isset( $condition['installed_since'] ) ) {
						break;
					}

					if ( ! $this->condition_installed_since( $condition['installed_since'] ) ) {
						$can_see_notice = false;
						break 2;
					}
					break;

				case 'date_range':
					if ( ! isset( $condition['start_date'] ) && ! isset( $condition['end_date'] ) ) {
						break;
					}

					if ( ! $this->condition_check_date_range( $condition['start_date'] ?? null, $condition['end_date'] ?? null ) ) {
						$can_see_notice = false;
						break 2;
					}

					break;
			}
		}

		// If no screens condition was set, only show this message on Sensei screens.
		if ( $can_see_notice && ! $has_screen_condition && ! $this->condition_check_screen( array( self::ALL_SENSEI_SCREENS_PLACEHOLDER ), $screen_id ) ) {
			$can_see_notice = false;
		}

		return $can_see_notice;
	}

	/**
	 * Check a PHP version condition.
	 *
	 * @param string $min_version Minimum PHP version.
	 * @return bool
	 */
	private function condition_check_min_php( string $min_version ): bool {
		return version_compare( phpversion(), $min_version, '>=' );
	}

	/**
	 * Check a WP version condition.
	 *
	 * @param string $min_version Minimum WP version.
	 * @return bool
	 */
	private function condition_check_min_wp( string $min_version ): bool {
		return version_compare( get_bloginfo( 'version' ), $min_version, '>=' );
	}

	/**
	 * Check a capability condition.
	 *
	 * @param array $allowed_caps Array of capabilities that the user must have.
	 * @return bool
	 */
	private function condition_check_capabilities( array $allowed_caps ): bool {
		$condition_pass = true;

		foreach ( $allowed_caps as $cap ) {
			if ( ! in_array( $cap, self::ALLOWED_CAP_CHECKS, true ) ) {
				continue;
			}

			if ( ! current_user_can( $cap ) ) {
				$condition_pass = false;
				break;
			}
		}

		return $condition_pass;
	}

	/**
	 * Check a screen condition.
	 *
	 * @param array  $allowed_screens Array of allowed screen IDs. `sensei*` is a special screen ID for any Sensei screen.
	 * @param string $screen_id       The screen ID.
	 *
	 * @return bool
	 */
	private function condition_check_screen( array $allowed_screens, $screen_id = null ): bool {
		/**
		 * Filter the array of screen IDs that are part of Sensei, and where we should show Sensei notices on.
		 *
		 * @since 4.12.0
		 *
		 * @hook sensei_notices_screen_ids
		 *
		 * @param {array} Array of Screen IDs that are part of Sensei.
		 * @return {array} Updated array of screen IDs that are part of Sensei.
		 */
		$sensei_screen_ids  = apply_filters( 'sensei_notices_screen_ids', self::SENSEI_SCREEN_IDS );
		$allowed_screen_ids = array_merge( $sensei_screen_ids, self::OTHER_ALLOWED_SCREEN_IDS );
		$condition_pass     = true;

		if ( in_array( 'sensei*', $allowed_screens, true ) ) {
			$allowed_screens = array_merge( $allowed_screens, $sensei_screen_ids );
		}

		$screens   = array_intersect( $allowed_screen_ids, $allowed_screens );
		$screen_id = $screen_id ?? $this->get_screen_id();

		if ( ! $screen_id || ! in_array( $screen_id, $screens, true ) ) {
			$condition_pass = false;
		}

		return $condition_pass;
	}

	/**
	 * Check an "installed since" condition
	 *
	 * @since 4.10.0
	 *
	 * @param int|string $installed_since Time to check the installation time for.
	 *
	 * @return bool
	 */
	private function condition_installed_since( $installed_since ): bool {
		$installed_at = get_option( 'sensei_installed_at' );
		if ( $installed_since && is_string( $installed_since ) ) {
			$installed_since = strtotime( '-' . $installed_since );
		}
		if ( ! $installed_at || ! $installed_since ) {
			return false;
		}
		return $installed_at <= $installed_since;
	}

	/**
	 * Check a date range condition.
	 *
	 * @since 4.14.0
	 *
	 * @param ?string $start_date_str Start date.
	 * @param ?string $end_date_str   End date.
	 *
	 * @return bool
	 */
	private function condition_check_date_range( ?string $start_date_str, ?string $end_date_str ): bool {
		$now = new DateTime();

		// Defaults to WP timezone, but can be overridden by passing string that includes timezone.
		$start_date = $start_date_str ? date_create( $start_date_str, wp_timezone() ) : null;
		$end_date   = $end_date_str ? date_create( $end_date_str, wp_timezone() ) : null;

		// If the passed date strings are invalid, don't show the notice.
		if ( false === $start_date || false === $end_date ) {
			return false;
		}

		if ( $start_date && $now < $start_date ) {
			return false;
		}

		if ( $end_date && $now > $end_date ) {
			return false;
		}

		return true;
	}

	/**
	 * Check a plugin condition.
	 *
	 * @param array $allowed_plugins Array of the plugins to check for.
	 *
	 * @return bool
	 */
	private function condition_check_plugin( array $allowed_plugins ): bool {
		$condition_pass = true;
		$active_plugins = $this->get_active_plugins();

		foreach ( $allowed_plugins as $plugin_basename => $plugin_condition ) {
			$plugin_active  = isset( $active_plugins[ $plugin_basename ] );
			$plugin_version = isset( $active_plugins[ $plugin_basename ]['Version'] ) ? $active_plugins[ $plugin_basename ]['Version'] : false;

			if ( false === $plugin_condition ) {
				// The plugin should not be active.
				if ( $plugin_active ) {
					$condition_pass = false;
					break;
				}
			} elseif ( true === $plugin_condition ) {
				// The plugin just needs to be active.
				if ( ! $plugin_active ) {
					$condition_pass = false;
					break;
				}
			} elseif ( isset( $plugin_condition['min'] ) || isset( $plugin_condition['max'] ) ) {
				// There is a plugin version condition, but we expect the plugin to be activated.
				if ( ! $plugin_active ) {
					$condition_pass = false;
					break;
				}

				if ( isset( $plugin_condition['min'] ) && version_compare( $plugin_version, $plugin_condition['min'], '<' ) ) {
					// If the activated plugin version is older than the minimum required, do not show the notice.
					$condition_pass = false;
					break;
				}

				if ( isset( $plugin_condition['max'] ) && version_compare( $plugin_version, $plugin_condition['max'], '>' ) ) {
					// If the activated plugin version is newer than the maximum required, do not show the notice.
					$condition_pass = false;
					break;
				}
			}
		}

		return $condition_pass;
	}

	/**
	 * Partial wrapper for for `get_plugins()` function. Filters out non-active plugins.
	 *
	 * @return array Key is basename of active plugins and value is version.
	 */
	protected function get_active_plugins() {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$plugins = get_plugins();
		foreach ( $plugins as $plugin_basename => $plugin_data ) {
			if ( ! is_plugin_active( $plugin_basename ) ) {
				unset( $plugins[ $plugin_basename ] );
			}
		}

		return $plugins;
	}

	/**
	 * Normalize notices.
	 *
	 * @param array $notice The notice configuration.
	 *
	 * @return array
	 */
	private function normalize_notice( $notice ) {
		if ( ! isset( $notice['conditions'] ) || ! is_array( $notice['conditions'] ) ) {
			$notice['conditions'] = array();
		}

		if ( ! isset( $notice['type'] ) ) {
			$notice['type'] = 'site-wide';
		}

		if ( 'site-wide' === $notice['type'] ) {
			// Only admins can see and manage site-wide notifications.
			$notice['conditions'][] = array(
				'type'         => 'user_cap',
				'capabilities' => array( 'manage_options' ),
			);
		}

		$notice_levels = array( 'error', 'warning', 'success', 'info' );
		if ( ! isset( $notice['level'] ) || ! in_array( $notice['level'], $notice_levels, true ) ) {
			$notice['level'] = 'info';
		}

		if ( ! isset( $notice['dismissible'] ) ) {
			$notice['dismissible'] = true;
		}

		return $notice;
	}

	/**
	 * Check to see if this notice is dismissed.
	 *
	 * @param string $notice_id            Unique identifier for the notice.
	 * @param bool   $is_user_notification True if this is for a user notification (vs site-wide notification).
	 *
	 * @return bool
	 */
	private function is_notice_dismissed( $notice_id, $is_user_notification ) {
		$dismissed_notices = $this->get_dismissed_notices( $is_user_notification );

		return in_array( $notice_id, $dismissed_notices, true );
	}

	/**
	 * Get the dismissed notifications (either for the user or site-wide).
	 *
	 * @param bool $is_user_notification True if this is for a user notification (vs site-wide notification).
	 *
	 * @return array
	 */
	private function get_dismissed_notices( $is_user_notification ) {
		if ( $is_user_notification ) {
			$dismissed_notices = get_user_meta( get_current_user_id(), self::DISMISSED_NOTICES_USER_META, true );
			if ( ! $dismissed_notices ) {
				$dismissed_notices = array();
			}
		} else {
			$dismissed_notices = get_option( self::DISMISSED_NOTICES_OPTION, array() );
		}

		return $dismissed_notices;
	}

	/**
	 * Save dismissed notices.
	 *
	 * @param array $dismissed_notices Array of dismissed notices.
	 * @param bool  $is_user_notification True if we are setting user notifications (vs site-wide notifications).
	 */
	private function save_dismissed_notices( $dismissed_notices, $is_user_notification ) {
		if ( $is_user_notification ) {
			update_user_meta( get_current_user_id(), self::DISMISSED_NOTICES_USER_META, $dismissed_notices );
		} else {
			update_option( self::DISMISSED_NOTICES_OPTION, $dismissed_notices );
		}
	}

	/**
	 * Handle the dismissal of the notice.
	 *
	 * @access private
	 */
	public function handle_notice_dismiss() {
		check_ajax_referer( self::DISMISS_NOTICE_NONCE_ACTION, 'nonce' );

		$notices   = $this->get_notices();
		$notice_id = isset( $_POST['notice'] ) ? sanitize_text_field( wp_unslash( $_POST['notice'] ) ) : false;
		if ( ! $notice_id || ! isset( $notices[ $notice_id ] ) ) {
			return;
		}

		$notice = $this->normalize_notice( $notices[ $notice_id ] );

		$is_user_notification = 'user' === $notice['type'];
		if (
			! $notice['dismissible']
			|| ( ! $is_user_notification && ! current_user_can( 'manage_options' ) )
		) {
			wp_die( '', '', 403 );
		}

		$dismissed_notices   = $this->get_dismissed_notices( $is_user_notification );
		$dismissed_notices[] = $notice_id;

		$this->save_dismissed_notices( $dismissed_notices, $is_user_notification );
	}
}