gocodebox/lifterlms-rest

View on GitHub
includes/models/class-llms-rest-webhook.php

Summary

Maintainability
B
6 hrs
Test Coverage
A
94%
<?php
/**
 * Webhook Model
 *
 * @package LifterLMS_REST/Models
 *
 * @since 1.0.0-beta.1
 * @version 1.0.0-beta.11
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_REST_Webhook class
 *
 * @since 1.0.0-beta.1
 * @since 1.0.0-beta.11 When validating a resource:
 *                      - Skipped autosaves and revisions.
 *                      - Implemented a new way to consider a resource as just created. Thanks WooCoommerce.
 */
class LLMS_REST_Webhook extends LLMS_REST_Webhook_Data {

    /**
     * Delivers the webhook
     *
     * @since 1.0.0-beta.1
     *
     * @param array $args Numeric array of arguments from the originating hook.
     * @return void
     */
    public function deliver( $args ) {

        $start   = microtime( true );
        $payload = $this->get_payload( $args );

        $http_args = array(
            'method'      => 'POST',
            'timeout'     => 60,
            'redirection' => 0,
            'user-agent'  => $this->get_user_agent(),
            'body'        => trim( wp_json_encode( $payload ) ),
            'headers'     => array(
                'Content-Type' => 'application/json',
            ),
        );

        /**
         * Modify HTTP args used to deliver the webhook
         *
         * @since 1.0.0-beta.1
         *
         * @param array             $http_args HTTP request args suitable for `wp_remote_request()`.
         * @param LLMS_REST_Webhook $this      Webhook object.
         * @param mixed             $args      First argument passed to the action triggering the webhook.
         */
        $http_args = apply_filters( 'llms_rest_webhook_delivery_args', $http_args, $this, $args );

        $delivery_id = wp_hash( $this->get( 'id' ) . strtotime( 'now' ) );

        $http_args['headers'] = array_merge(
            $http_args['headers'],
            array(
                'X-LLMS-Webhook-Source'    => home_url( '/' ),
                'X-LLMS-Webhook-Topic'     => $this->get( 'topic' ),
                'X-LLMS-Webhook-Resource'  => $this->get_resource(),
                'X-LLMS-Webhook-Event'     => $this->get_event(),
                'X-LLMS-Webhook-Signature' => $this->get_delivery_signature( $http_args['body'] ),
                'X-LLMS-Webhook-ID'        => $this->get( 'id' ),
                'X-LLMS-Delivery-ID'       => $delivery_id,
            )
        );

        $res = wp_safe_remote_request( $this->get( 'delivery_url' ), $http_args );

        $duration = round( microtime( true ) - $start, 5 );

        $this->delivery_after( $delivery_id, $http_args, $res, $duration );

        /**
         * Fires after a webhook is delivered
         *
         * @since 1.0.0-beta.1
         *
         * @param array             $http_args HTTP request args.
         * @param WP_Error|array    $res       Remote response.
         * @param int               $duration  Executing time.
         * @param array             $args      Numeric array of arguments from the originating hook.
         * @param LLMS_REST_Webhook $this      Webhook object.
         */
        do_action( 'llms_rest_webhook_delivery', $http_args, $res, $duration, $args, $this );

    }

    /**
     * Fires after delivery
     *
     * Logs data when loggind enabled and updates state data.
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.17 Stop setting the webhook's property `pending_delivery` to 0.
     *                      We now rely on the method `is_already_processed()` to determine whether the webhook delivering should be avoided.
     *
     * @param string $delivery_id Webhook delivery id (for logging).
     * @param array  $req_args    HTTP Request Arguments used to deliver the webhook.
     * @param array  $res         Results from `wp_safe_remote_request()`.
     * @param float  $duration    Time (in microseconds) it took to generate and deliver the webhook.
     * @return void
     */
    protected function delivery_after( $delivery_id, $req_args, $res, $duration ) {

        // Parse response.
        if ( is_wp_error( $res ) ) {
            $res_code    = $res->get_error_code();
            $res_message = $res->get_error_message();
            $res_headers = array();
            $res_body    = '';
        } else {
            $res_code    = wp_remote_retrieve_response_code( $res );
            $res_message = wp_remote_retrieve_response_message( $res );
            $res_headers = wp_remote_retrieve_headers( $res );
            $res_body    = wp_remote_retrieve_body( $res );
        }

        if ( defined( 'LLMS_REST_WEBHOOK_DELIVERY_LOGGING' ) && LLMS_REST_WEBHOOK_DELIVERY_LOGGING ) {

            $message = array(
                'Delivery ID' => $delivery_id,
                'Date'        => date_i18n( __( 'M j, Y @ H:i', 'lifterlms' ), strtotime( 'now' ), true ),
                'URL'         => $this->get( 'delivery_url' ),
                'Duration'    => $duration,
                'Request'     => array(
                    'Method'  => $req_args['method'],
                    'Headers' => array_merge(
                        array(
                            'User-Agent' => $req_args['user-agent'],
                        ),
                        $req_args['headers']
                    ),
                ),
                'Body'        => wp_slash( $req_args['body'] ),
                'Response'    => array(
                    'Code'    => $res_code,
                    'Message' => $res_message,
                    'Headers' => $res_headers,
                    'Body'    => $res_body,
                ),
            );

            if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
                $message['Webhook Delivery']['Body']             = 'Webhook body is not logged unless WP_DEBUG mode is turned on.';
                $message['Webhook Delivery']['Response']['Body'] = 'Webhook body is not logged unless WP_DEBUG mode is turned on.';
            }

            llms_log( $message, sprintf( 'webhook-%d', $this->get( 'id' ) ) );

        }

        // Check for a success, which is a 2xx, 301 or 302 Response Code.
        if ( absint( $res_code ) >= 200 && absint( $res_code ) <= 302 ) {
            $this->set( 'failure_count', 0 );
        } else {
            $this->set_delivery_failure();
        }

    }

    /**
     * Add actions for all the webhooks hooks
     *
     * @since 1.0.0-beta.1
     *
     * @return void
     */
    public function enqueue() {

        foreach ( $this->get_hooks() as $hook => $args ) {
            add_action( $hook, array( $this, 'process_hook' ), 10, $args );
        }

    }

    /**
     * Determine if the current action is valid for the webhook
     *
     * @since 1.0.0-beta.1
     *
     * @param array $args Numeric array of arguments from the originating hook.
     * @return bool
     */
    protected function is_valid_action( $args ) {

        $ret = true;
        switch ( current_action() ) {

            case 'wp_trash_post':
            case 'delete_post':
            case 'untrashed_post':
                $ret = $this->is_valid_post_action( $args[0] );
                break;

            case 'user_register':
            case 'profile_update':
            case 'delete_user':
                $ret = $this->is_valid_user_action( $args[0] );
                break;

        }

        /**
         * Determine if the current action is valid for the webhook
         *
         * @param bool              $ret  Whether or not the action is valid.
         * @param array             $args Numeric array of arguments from the originating hook.
         * @param LLMS_REST_Webhook $this Webhook object.
         */
        return apply_filters( 'llms_rest_webhook_is_valid_action', $ret, $args, $this );

    }

    /**
     * Determine if the current post-related action is valid for the webhook
     *
     * @since 1.0.0-beta.1
     *
     * @param int $post_id WP Post ID.
     * @return bool
     */
    protected function is_valid_post_action( $post_id ) {

        $post_type = get_post_type( $post_id );

        // Check the post type is a supported post type.
        if ( ! in_array( get_post_type( $post_id ), LLMS_REST_API()->webhooks()->get_post_type_resources(), true ) ) {
            return false;
        }

        // Ensure the current action matches the resource for the current webhook.
        if ( str_replace( 'llms_', '', $post_type ) !== $this->get_resource() ) {
            return false;
        }

        return true;

    }

    /**
     * Determine if the the resource is valid for the webhook
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.11 Skipped autosaves and revisions.
     *                      Implemented a new way to consider a resource as just created. Thanks WooCoommerce.
     *
     * @param array $args Numeric array of arguments from the originating hook.
     * @return bool
     */
    protected function is_valid_resource( $args ) {

        $resource = $this->get_resource();

        if ( in_array( $resource, LLMS_REST_API()->webhooks()->get_post_type_resources(), true ) ) {

            $post_resource = get_post( absint( $args[0] ) );

            // Ignore auto-drafts.
            if ( in_array( get_post_status( $post_resource ), array( 'new', 'auto-draft' ), true ) ) {
                return false;
            }

            if ( false !== strpos( current_action(), 'save_post' ) || false !== strpos( current_action(), 'edit_post' ) ) {

                // Ignore autosaves and revisions.
                if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || is_int( wp_is_post_revision( $post_resource ) ) || is_int( wp_is_post_autosave( $resource ) ) ) {
                    return false;
                }

                // Drafts don't have post_date_gmt so calculate it here.
                $gmt_date = get_gmt_from_date( $post_resource->post_date );

                // A resource is considered created when the hook is executed within 10 seconds of the post creation date.
                $resource_created = ( ( time() - 10 ) <= strtotime( $gmt_date ) );

                $event = $this->get_event();

                if ( ( 'created' === $event && false !== strpos( current_action(), 'save_post' ) ) && ! $resource_created ) {
                    return false;
                } elseif ( ( 'updated' === $event && false !== strpos( current_action(), 'edit_post' ) ) && $resource_created ) {
                    return false;
                }
            }
        }

        return true;

    }

    /**
     * Determine if the current user-related action is valid for the webhook
     *
     * @since 1.0.0-beta.1
     *
     * @param int $user_id WP User ID.
     * @return bool
     */
    protected function is_valid_user_action( $user_id ) {

        $user = get_userdata( $user_id );

        if ( ! $user ) {
            return false;
        }

        $resource = $this->get_resource();
        if ( 'student' === $resource && ! in_array( 'student', (array) $user->roles, true ) ) {
            return false;
        } elseif ( 'instructor' === $resource && ! user_can( $user_id, 'lifterlms_instructor' ) ) {
            return false;
        }

        return true;

    }

    /**
     * Processes information from the origination action hook
     *
     * Determines if the webhook should be delivered and whether or not it should be scheduled or delivered immediately.
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.17 Mark this hook's first argument as processed to ensure it doesn't get processed again within the current request.
     *                      And don't rely anymore on the webhook's `pending_delivery` property to achieve the same goal.
     * @since 1.0.0 Remove the processed flag as the ActionScheduler prevents multiple additions of the same hook.
     *
     * @param mixed ...$args Arguments from the hook.
     * @return int|false Timestamp of the scheduled event when the webhook is successfully scheduled.
     *                   false if the webhook should not be delivered or has already been delivered in the last 5 minutes.
     */
    public function process_hook( ...$args ) {

        if ( ! $this->should_deliver( $args ) ) {
            return false;
        }

        /**
         * Disable background processing of webhooks by returning a falsy
         *
         * Note: disabling async processing may create delays for users of your site.
         *
         * @param bool              $async Whether async processing is enabled or not.
         * @param LLMS_REST_Webhook $this  Webhook object.
         * @param array             $args  Numeric array of arguments from the originating hook.
         */
        if ( apply_filters( 'llms_rest_webhook_deliver_async', true, $this, $args ) ) {
            return $this->schedule( $args );
        }

        return $this->deliver( $args );

    }

    /**
     * Perform a test ping to the delivery url
     *
     * @since 1.0.0-beta.1
     *
     * @return true|WP_Error
     */
    public function ping() {

        $pre = apply_filters( 'llms_rest_webhook_pre_ping', false, $this->get( 'id' ) );
        if ( false !== $pre ) {
            return $pre;
        }

        $ping = wp_safe_remote_post(
            $this->get( 'delivery_url' ),
            array(
                'user-agent' => $this->get_user_agent(),
                'body'       => sprintf( 'webhook_id=%d', $this->get( 'id' ) ),
            )
        );

        $res_code = wp_remote_retrieve_response_code( $ping );

        if ( is_wp_error( $ping ) ) {
            // Translators: %s = Error message.
            return new WP_Error( 'llms_rest_webhook_ping_unreachable', sprintf( __( 'Could not reach the delivery url: "%s".', 'lifterlms' ), $ping->get_error_message() ) );
        }

        if ( 200 !== $res_code ) {
            // Translators: %d = Response code.
            return new WP_Error( 'llms_rest_webhook_ping_not_200', sprintf( __( 'The delivery url returned the response code "%d".', 'lifterlms' ), absint( $res_code ) ) );
        }

        return true;

    }

    /**
     * Determines if an originating action qualifies for webhook delivery
     *
     * @since 1.0.0-beta.1
     * @since [verison] Removed the "is already processed" check since ActionScheduler prevents duplicates.
     *
     * @param array $args Numeric array of arguments from the originating hook.
     * @return bool
     */
    protected function should_deliver( $args ) {

        $deliver = ( 'active' === $this->get( 'status' ) ) // Must be active.
            && $this->is_valid_action( $args ) // Valid action.
            && $this->is_valid_resource( $args ); // Valid resource.

        /**
         * Skip or hijack webhook delivery scheduling
         *
         * @param bool              $deliver Whether or not to deliver webhook delivery.
         * @param LLMS_REST_Webhook $this    Webhook object.
         * @param array             $args    Numeric array of arguments from the originating hook.
         */
        return apply_filters( 'llms_rest_webhook_should_deliver', $deliver, $this, $args );

    }

    /**
     * Schedule the webhook for async delivery
     *
     * @since 1.0.0-beta.1
     * @since 1.0.0-beta.17 Stop setting the webhook's property `pending_delivery` to 1 when scheduling the delivery.
     *                      We now rely on the method `is_already_processed()` to determine whether the webhook scheduling should be avoided.
     *
     * @param array $args Numeric array of arguments from the originating hook.
     * @return bool
     */
    protected function schedule( $args ) {

        // Remove object & array arguments before scheduling to avoid hitting column index size issues imposed by the ActionScheduler lib.
        foreach ( $args as $index => &$arg ) {
            if ( is_array( $arg ) || is_object( $arg ) ) {
                $arg = null;
            }
        }

        $schedule_args = array(
            'webhook_id' => $this->get( 'id' ),
            'args'       => $args,
        );

        $next = as_next_scheduled_action( 'lifterlms_rest_deliver_webhook_async', $schedule_args, 'llms-webhooks' );

        /**
         * Determines the time period required to wait between delivery of the webhook
         *
         * If the webhook has already been scheduled within this time period it will not be sent again
         * until the period expires. For example, the default time period is 300 seconds (5 minutes).
         * If the webhook is triggered at 12:00pm it will be scheduled. If it is triggered again at 12:03pm the
         * second occurrence will not be scheduled. If it is triggerd again at 12:06pm this third occurrence will
         * again be scheduled.
         *
         * @since 1.0.0-beta.1
         *
         * @param int               $delay Time (in seconds).
         * @param array             $args  Numeric array of arguments from the originating hook.
         * @param LLMS_REST_Webhook $this  Webhook object.
         */
        $delay = apply_filters( 'llms_rest_webhook_repeat_delay', 300, $args, $this );

        if ( ! $next || $next >= ( $delay + gmdate( 'U' ) ) ) {

            return as_schedule_single_action( time(), 'lifterlms_rest_deliver_webhook_async', $schedule_args, 'llms-webhooks' ) ? true : false;

        }

        return false;

    }

}