gocodebox/lifterlms

View on GitHub
includes/class-llms-events.php

Summary

Maintainability
B
6 hrs
Test Coverage
B
83%
<?php
/**
 * LifterLMS Event management.
 *
 * @package LifterLMS/Classes
 *
 * @since 3.36.0
 * @version 6.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Events class.
 *
 * @since 3.36.0
 * @since 3.36.1 Improve performances when checking if an event is valid in `LLMS_Events->is_event_valid()`.
 *               Remove redundant check on `is_singular()` and `is_post_type_archive()` in `LLMS_Events->should_track_client_events()`.
 * @since 3.37.14 Added `store_tracking_events()` method.
 *                Moved most of the `store_cookie()` method's logic into `store_tracking_events()`.
 * @since 3.37.15 Excluded `page.*` events in order to keep the events table small.
 * @since 5.3.0 Replace singleton code with `LLMS_Trait_Singleton`.
 * @since 6.0.0 Removed the deprecated `LLMS_Events::$_instance` property.
 */
class LLMS_Events {

    use LLMS_Trait_Singleton;

    /**
     * List of registered event types.
     *
     * @var array
     */
    protected $registered_events = array();

    /**
     * Private Constructor
     *
     * @since 3.36.0
     * @since 4.5.0 Register events at `init` hook with priority 9 in place of 10.
     *
     * @return void
     */
    private function __construct() {

        add_action( 'init', array( $this, 'register_events' ), 9 );
        add_action( 'init', array( $this, 'store_cookie' ) );

    }

    /**
     * Retrieves an array of client settings used to initialize the JS Tracking instance on the frontend.
     *
     * @since 3.36.0
     *
     * @return array
     */
    public function get_client_settings() {

        $events = ! $this->should_track_client_events() ? array() : array_keys( array_filter( $this->get_registered_events() ) );

        /**
         * Filter client-side tracking settings
         *
         * @since 3.36.0
         *
         * @param array $settings {
         *     Hash of client-side settings.
         *
         *     @type string $nonce Nonce used to verify client-side events.
         *     @type string[] $events Array of events that should be tracked.
         * }
         */
        return apply_filters(
            'llms_events_get_client_settings',
            array(
                'nonce'  => wp_create_nonce( 'llms-tracking' ),
                'events' => $events,
            )
        );

    }

    /**
     * Retrieve an array of valid events.
     *
     * @since 3.36.0
     *
     * @return array Array key is the event name and array value is used to determine if the key is a client-side event.
     */
    public function get_registered_events() {
        return $this->registered_events;
    }

    /**
     * Determine if the event string is registered and valid.
     *
     * @since 3.36.0
     * @since 3.36.1 Use more performant `array_key_exists( $key, $array_assoc )` in place of `in_array( $key, array_keys( $array_assoc ), true )`.
     *
     * @param string $event Event string ({$event_type}.{$event_action}). EG: "account.signon".
     * @return bool
     */
    protected function is_event_valid( $event ) {

        return array_key_exists( $event, $this->get_registered_events() );

    }

    /**
     * Prepares partial events from client-side event data.
     *
     * @since 3.36.0
     *
     * @param array $raw_event Raw event from client-side data.
     * @return array
     */
    public function prepare_event( $raw_event = array() ) {

        if ( ! isset( $raw_event['event'] ) ) {
            // Translators: %s = Event field key.
            return new WP_Error( 'llms_events_missing_event', sprintf( __( 'The event is missing the "%s" field.', 'lifterlms' ), 'event' ) );
        }

        $event    = explode( '.', $raw_event['event'] );
        $prepared = array(
            'actor_id'     => get_current_user_id(),
            'event_type'   => $event[0],
            'event_action' => $event[1],
            'meta'         => isset( $raw_event['meta'] ) ? $raw_event['meta'] : array(),
        );

        // Convert timestamps to MYSQL date.
        if ( isset( $raw_event['time'] ) && is_numeric( $raw_event['time'] ) ) {
            $prepared['date'] = date( 'Y-m-d H:i:s', $raw_event['time'] );
        }

        if ( isset( $raw_event['url'] ) ) {
            $id = url_to_postid( $raw_event['url'] );
            if ( ! $id ) {
                // Translators: %s = URL.
                return new WP_Error( 'llms_events_invalid_url', sprintf( __( 'The URL "%s" cannot be mapped to a valid post object.', 'lifterlms' ), esc_url( $raw_event['url'] ) ) );
            }
            $prepared['object_id']   = $id;
            $prepared['object_type'] = str_replace( 'llms_', '', get_post_type( $id ) );
        } elseif ( isset( $raw_event['object_id'] ) && isset( $raw_event['object_type'] ) ) {
            $prepared['object_id']   = $raw_event['object_id'];
            $prepared['object_type'] = $raw_event['object_type'];
        }

        return $prepared;

    }

    /**
     * Store an event in the database.
     *
     * @since 3.36.0
     * @since 4.5.0 Fixed event session end not recorded on sign-out.
     *
     * @param array $args {
     *     Event data
     *
     *     @type int $actor_id WP_User ID.
     *     @type string $object_type Type of object being acted upon (post,user,comment,etc...).
     *     @type int $object_id WP_Post ID, WP_User ID, WP_Comment ID, etc...
     *     @type string $event_type Type of event (account, page, course, etc...).
     *     @type string $event_action The event action or verb (signon,viewed,launched,etc...).
     * }
     * @return LLMS_Event|WP_Error
     */
    public function record( $args = array() ) {

        $err = new WP_Error();

        foreach ( array( 'actor_id', 'object_type', 'object_id', 'event_type', 'event_action' ) as $key ) {
            if ( ! in_array( $key, array_keys( $args ), true ) ) {
                // Translators: %s = key name of the missing field.
                $err->add( 'llms_event_record_missing_field', sprintf( __( 'Missing required field: "%s".', 'lifterlms' ), $key ) );
            }
        }

        if ( $err->get_error_codes() ) {
            return $err;
        }

        $event = sprintf( '%1$s.%2$s', $args['event_type'], $args['event_action'] );

        if ( ! $this->is_event_valid( $event ) ) {
            // Translators: %s = Submitted event string.
            return new WP_Error( 'llms_event_record_invalid_event', sprintf( __( 'The event "%s" is invalid.', 'lifterlms' ), $event ) );
        }

        $args = $this->sanitize_raw_event( $args );
        $meta = isset( $args['meta'] ) ? $args['meta'] : null;
        unset( $args['meta'] );

        if ( ! in_array( $event, array( 'session.start', 'session.end' ), true ) ) {

            // Start a session if one isn't open.
            $sessions = LLMS_Sessions::instance();
            $user_id  = 'account.signon' === $event && isset( $args['actor_id'] ) ? $args['actor_id'] : null;

            if ( false === $sessions->get_current( $user_id ) ) {
                $sessions->start( $user_id );
            }
        }

        $llms_event = new LLMS_Event();
        if ( ! $llms_event->setup( $args )->save() ) {
            $err->add( 'llms_event_recored_unknown_error', __( 'An unknown error occurred during event creation.', 'lifterlms' ) );
            return $err;
        }
        if ( $meta && ! empty( $meta ) ) {
            $llms_event->set_metas( $meta, true );
        }

        // End the current session on signout.
        if ( 'account.signout' === $event ) {
            LLMS_Sessions::instance()->end_current();
        }

        return $llms_event;

    }

    /**
     * Record multiple events.
     *
     * Events are recorded with an SQL transaction. If any errors are encountered the transaction is rolled back (not events are recorded).
     *
     * @since 3.36.0
     *
     * @param array[] $events Array of event hashes. See LLMS_Events::record() for hash description.
     * @return LLMS_Event[]|WP_Error Array of recorded events on success or WP_Error on failure.
     */
    public function record_many( $events = array() ) {

        global $wpdb;
        $wpdb->query( 'START TRANSACTION' );

        $recorded = array();
        $errors   = array();
        foreach ( $events as $event ) {

            $stat = $this->record( $event );
            if ( is_wp_error( $stat ) ) {
                $stat->add_data( $event );
                $errors[] = $stat;
            } else {
                $recorded[] = $stat;
            }
        }

        if ( count( $errors ) ) {
            $wpdb->query( 'ROLLBACK' );
            return new WP_Error( 'llms_events_record_many_errors', __( 'There was one or more errors encountered while recording the events.', 'lifterlms' ), $errors );
        }

        $wpdb->query( 'COMMIT' );

        return $recorded;

    }

    /**
     * Register event types
     *
     * @since 3.36.0
     * @since 3.37.15 Excluded `page.*` events in order to keep the events table small.
     *
     * @return void
     */
    public function register_events() {

        $events = array(
            'account.signon'  => false,
            'account.signout' => false,
            'session.start'   => false,
            'session.end'     => false,

            /*
            'page.load'       => true,
            'page.exit'       => true,
            'page.focus'      => true,
            'page.blur'       => true,
            */
        );

        /**
         * Filter the list of registered events.
         *
         * Allows 3rd parties to register (or unregister) tracked events.
         *
         * @since 3.36.0
         *
         * @param array $events Array of events. Array key is the event name and array value is used to determine if the key is a client-side event.
         */
        $this->registered_events = apply_filters( 'llms_get_registered_events', $events );

    }

    /**
     * Recursively sanitize event data.
     *
     * @since 3.36.0
     *
     * @param array $raw Event information array.
     * @return array
     */
    protected function sanitize_raw_event( $raw ) {

        $clean = array();

        foreach ( $raw as $key => $val ) {

            // This will recursively handle any metadata submitted.
            if ( is_array( $val ) ) {
                $val = $this->sanitize_raw_event( $val );
            } elseif ( in_array( $key, array( 'actor_id', 'object_id' ), true ) ) {
                // cast id fields to int.
                $val = absint( $val );
            } else {
                // everything else is a text field.
                $val = sanitize_text_field( $val );
            }

            // Sanitize the key. This will ensure no dirty keys are submitted in metadata.
            $key = is_numeric( $key ) ? $key : sanitize_text_field( $key );

            $clean[ $key ] = $val;

        }

        return $clean;

    }

    /**
     * Determine if client side events from the current page should be tracked.
     *
     * @since 3.36.0
     *
     * @return boolean
     */
    protected function should_track_client_events() {

        $ret = false;

        /**
         * Filter the post types that should be tracked
         *
         * @since 3.36.0
         * @since 3.36.1 Remove redundant check on `is_singular()` and `is_post_type_archive()`.
         *
         * @param string[]|string $post_types An array of post type names or a pre-defined setting as a string.
         *                                    "llms" uses all public LifterLMS and LifterLMS Add-on post types.
         *                                    "all" tracks everything.
         */
        $post_types = apply_filters( 'llms_tracking_post_types', 'llms' );

        if ( 'all' === $post_types ) {
            $ret = true;
        } elseif ( 'llms' === $post_types ) {

            // Filter public post types to include LifterLMS public post types.
            $post_types = array_keys( get_post_types( array( 'public' => true ) ) );
            foreach ( $post_types as $key => $type ) {
                if ( ! in_array( $type, array( 'course', 'lesson' ), true ) && 0 !== strpos( $type, 'llms_' ) ) {
                    unset( $post_types[ $key ] );
                }
            }
        }

        if ( ! is_array( $post_types ) ) {
            $ret = false;
        } elseif ( is_singular( $post_types ) ) {
            $ret = true;
        } elseif ( is_post_type_archive( $post_types ) ) {
            $ret = true;
        } elseif ( is_llms_account_page() || is_llms_checkout() ) {
            $ret = true;
        }

        /**
         * Filters whether or not the current page should track client-side events
         *
         * @since 3.36.0
         *
         * @param bool $ret Whether or not to track the current page.
         * @param string[] $post_types Array of post types that should be tracked.
         */
        return apply_filters( 'llms_tracking_should_track_client_events', $ret, $post_types );

    }

    /**
     * Store event data saved in the tracking cookie.
     *
     * @since 3.36.0
     * @since 3.37.14 Moved most of the logic into `store_tracking_events()` method.
     *                Bail if we're sending the tracking events via ajax.
     * @since 4.3.1 Set a secure cookie when possible.
     *
     * @return void
     */
    public function store_cookie() {

        if ( wp_doing_ajax() && ! empty( $_POST['llms-tracking'] ) ) {// phpcs:ignore: WordPress.Security.NonceVerification.Missing -- Nonce verified in `$this->store_tracking_events()` method.
            return;
        }

        // Bail if no `llms-tracking` cookie.
        if ( empty( $_COOKIE['llms-tracking'] ) ) {
            return;
        }

        $this->store_tracking_events( wp_unslash( $_COOKIE['llms-tracking'] ) ); // phpcs:ignore: WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via $this->sanitize_raw_event().

        // Cookie reset.
        llms_setcookie( 'llms-tracking', '', time() - 60, COOKIEPATH ? COOKIEPATH : '/', COOKIE_DOMAIN, llms_is_site_https() && is_ssl() );

    }

    /**
     * Store event data saved in the tracking cookie.
     *
     * @since 3.37.14
     *
     * @param string $tracking The `llms-tracking` data in JSON format.
     * @return (boolean|WP_Error) Returns WP_Error when nonce verification fails or unauthenticated user, `true` otherwise.
     */
    public function store_tracking_events( $tracking ) {

        $tracking = json_decode( $tracking, true );

        if ( ! empty( $tracking['nonce'] ) && wp_verify_nonce( $tracking['nonce'], 'llms-tracking' ) && get_current_user_id() ) {

            if ( ! empty( $tracking['events'] ) && is_array( $tracking['events'] ) ) {

                foreach ( $tracking['events'] as $event ) {

                    $event = $this->prepare_event( $event );

                    if ( ! is_wp_error( $event ) ) {
                        $this->record( $event );
                    }
                }
            }
        } else {
            return new WP_Error( 'llms_events_tracking_unauthorized', __( 'You\'re not allowed to store tracking events', 'lifterlms' ) );
        }

        return true;
    }

}