Source: classes/API/SubscriptionsController.php

<?php
/**
 * Subscription REST API endpoint
 *
 * @package  distributor
 */

namespace Distributor\API;

/**
 * Subscription controller REST API class
 */
class SubscriptionsController extends \WP_REST_Controller {

	/**
	 * Post type.
	 *
	 * @var string
	 */
	protected $post_type;

	/**
	 * Register controller
	 *
	 * @since 1.0
	 * @param string $post_type Post type.
	 */
	public function __construct( $post_type ) {
		$this->post_type = $post_type;
		$this->namespace = 'wp/v2';
		$obj             = get_post_type_object( $post_type );
		$this->rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name;

		$this->meta = new \WP_REST_Post_Meta_Fields( $this->post_type );
		add_filter( 'rest_authentication_errors', array( $this, 'dt_verify_signature_authentication' ) );

	}

	/**
	 * Register subscription routes
	 *
	 * @since 1.0
	 */
	public function register_routes() {

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => array(
						'post_id'        => array(
							'required'    => true,
							'description' => esc_html__( 'Post that is being subscribed to.', 'distributor' ),
							'type'        => 'integer',
						),
						'remote_post_id' => array(
							'required'    => true,
							'description' => esc_html__( 'Post on remote site that maps to subscription post.', 'distributor' ),
							'type'        => 'integer',
						),
						'target_url'     => array(
							'required'    => true,
							'description' => esc_html__( 'WordPress URL to notify.', 'distributor' ),
							'type'        => 'string',
						),
						'signature'      => array(
							'required'    => true,
							'description' => esc_html__( 'Subscription signature for post.', 'distributor' ),
							'type'        => 'string',
						),
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/receive',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'receive_item' ),
					'permission_callback' => array( $this, 'receive_item_permissions_check' ),
					'args'                => [
						'post_id'   => array(
							'required'    => true,
							'description' => esc_html__( 'Post to be updated.', 'distributor' ),
							'type'        => 'integer',
						),
						'signature' => array(
							'required'    => true,
							'description' => esc_html__( 'Signature for given signature', 'distributor' ),
							'type'        => 'string',
						),
					],
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/delete',
			array(
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
					'args'                => [
						'post_id'   => array(
							'required'    => true,
							'description' => esc_html__( 'Post with subscription.', 'distributor' ),
							'type'        => 'integer',
						),
						'signature' => array(
							'required'    => true,
							'description' => esc_html__( 'Signature for given subscription', 'distributor' ),
							'type'        => 'string',
						),
					],
				),
			)
		);
	}

	/**
	 * Authenticate the request via the signature if available.
	 *
	 * @param  WP_Error|null|bool $status The authentication status.
	 *
	 * @return WP_Error|null|bool The filtered authentication status.
	 */
	public function dt_verify_signature_authentication( $status ) {

		// Is the request authentication already handled?
		if ( null !== $status ) {
			return $status;
		}

		if ( ! empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
			$path = $GLOBALS['wp']->query_vars['rest_route'];
		} else {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- see wp_fix_server_vars().
			$path = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
		$request = new \WP_REST_Request( strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ), $path );
		$request->set_body_params( wp_unslash( $_POST ) ); // phpcs:ignore

		// If this is not a subscription request, return the original value.
		if ( '/dt_subscription/receive' !== $request->get_route() && '/dt_subscription/delete' !== $request->get_route() ) {
			return $status;
		}

		// If the signature is unset or empty, throw an error.
		if ( ( ! isset( $request['signature'] ) ) || empty( $request['signature'] ) ) {
			return new \WP_Error( 'rest_post_invalid_signature', esc_html__( 'Signature invalid or missing.', 'distributor' ), array( 'status' => 403 ) );
		}

		// If the post id is missing, throw an error.
		if ( empty( $request['post_id'] ) ) {
			return new \WP_Error( 'rest_post_invalid_post_id', esc_html__( 'Invalid post id.', 'distributor' ), array( 'status' => 403 ) );
		} else {

			$signature = get_post_meta( $request['post_id'], 'dt_subscription_signature', true );

			if ( $request['signature'] === $signature ) {
				return true;
			}
		}

		// No check was performed, return the original value.
		return $status;
	}

	/**
	 * Determine if receive endpoint permissions are correct.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @since  1.0
	 * @return true|\WP_Error True if the request has receive access, \WP_Error object otherwise.
	 */
	public function receive_item_permissions_check( $request ) {
		return true;
	}

	/**
	 * Receive a subscription update. We could just push using the existing REST API. However, in the scenario where
	 * we are receiving an update from a pulled post, we wouldn't have access to push since source connections are one-way
	 * intentionally.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @since  1.0
	 * @return WP_REST_Response|\WP_Error Response object on success, or \WP_Error object on failure.
	 */
	public function receive_item( $request ) {
		$post = get_post( (int) $request['post_id'] );
		if ( empty( $post ) ) {
			return new \WP_REST_Response( null, 404, [ 'X-Distributor-Post-Deleted' => 'yes' ] );
		}

		$original_post_id = get_post_meta( $request['post_id'], 'dt_original_post_id', true );

		if ( empty( $original_post_id ) ) {
			return new \WP_Error( 'rest_post_not_distributed', esc_html__( 'Post not distributed.', 'distributor' ), array( 'status' => 400 ) );
		}

		// This endpoint updates post data and unlinks posts
		if ( isset( $request['original_deleted'] ) ) {
			update_post_meta( $request['post_id'], 'dt_original_post_deleted', true );

			$response = new \WP_REST_Response();
			$response->set_data( array( 'updated' => true ) );

			return $response;
		} else {
			if ( empty( $request['post_data'] ) ) {
				return new \WP_Error( 'rest_post_no_data', esc_html__( 'No post data for update.', 'distributor' ), array( 'status' => 400 ) );
			}

			// When both sides of a subscription connection support Gutenberg, update with the raw content.
			$content = $request['post_data']['content'];
			if ( \Distributor\Utils\is_using_gutenberg( $post ) && isset( $request['post_data']['distributor_raw_content'] ) ) {
				if ( \Distributor\Utils\dt_use_block_editor_for_post_type( $post->post_type ) ) {
					$content = $request['post_data']['distributor_raw_content'];

					// Remove filters that may alter content updates.
					remove_all_filters( 'content_save_pre' );
				}
			}

			/**
			 * We save the update in meta in case the post is unlinked. If the post is re-linked, we'll
			 * apply the update
			 */
			$update = [
				'post_title'   => sanitize_text_field( $request['post_data']['title'] ),
				'post_name'    => sanitize_text_field( $request['post_data']['slug'] ),
				'post_content' => wp_kses_post( $content ),
				'post_excerpt' => wp_kses_post( $request['post_data']['excerpt'] ),
				// Todo: how do we properly sanitize this?
				'meta'         => ( isset( $request['post_data']['distributor_meta'] ) ) ? $request['post_data']['distributor_meta'] : [],
				'terms'        => ( isset( $request['post_data']['distributor_terms'] ) ) ? $request['post_data']['distributor_terms'] : [],
				'media'        => ( isset( $request['post_data']['distributor_media'] ) ) ? $request['post_data']['distributor_media'] : [],
			];

			// Limit taxonomy updates to those shown in the REST API.
			$rest_taxonomies = get_taxonomies( [ 'show_in_rest' => true ] );
			$rest_taxonomies = array_fill_keys( $rest_taxonomies, true );
			$update['terms'] = array_intersect_key( $update['terms'], $rest_taxonomies );

			update_post_meta( (int) $request['post_id'], 'dt_subscription_update', $update );

			$unlinked = (bool) get_post_meta( $request['post_id'], 'dt_unlinked', true );

			if ( ! empty( $unlinked ) ) {
				$response = new \WP_REST_Response();
				$response->set_data( array( 'updated' => false ) );

				return $response;
			}

			wp_update_post(
				[
					'ID'           => $request['post_id'],
					'post_title'   => $request['post_data']['title'],
					'post_content' => $content,
					'post_excerpt' => $request['post_data']['excerpt'],
					'post_name'    => $request['post_data']['slug'],
				]
			);

			/**
			 * We check if each of these exist since the API removes empty arrays from requests
			 */
			if ( ! empty( $request['post_data']['distributor_meta'] ) ) {
				\Distributor\Utils\set_meta( $request['post_id'], $request['post_data']['distributor_meta'] );
			}

			if ( ! empty( $request['post_data']['distributor_terms'] ) ) {
				\Distributor\Utils\set_taxonomy_terms( $request['post_id'], $request['post_data']['distributor_terms'] );
			}

			if ( ! empty( $request['post_data']['distributor_media'] ) ) {
				\Distributor\Utils\set_media( $request['post_id'], $request['post_data']['distributor_media'] );
			} else {
				// Remove any previously set featured image.
				delete_post_meta( (int) $request['post_id'], '_thumbnail_id' );
			}

			/**
			 * Action fired after receiving a subscription update from Distributor
			 *
			 * @since 1.3.8
			 * @hook dt_process_subscription_attributes
			 *
			 * @param {WP_Post}         $post    Updated post object.
			 * @param {WP_REST_Request} $request Request object.
			 */
			do_action( 'dt_process_subscription_attributes', $post, $request );

			$response = new \WP_REST_Response();
			$response->set_data( array( 'updated' => true ) );

			return $response;
		}
	}

	/**
	 * Helper function to build response array for a subscription
	 *
	 * @param  int $post_id Post ID.
	 * @since  1.0
	 */
	protected function get_response_array( $post_id ) {
		return array(
			'id'             => (int) $post_id,
			'post_id'        => (int) get_post_meta( $post_id, 'dt_subscription_post_id', true ),
			'remote_post_id' => (int) get_post_meta( $post_id, 'dt_subscription_remote_post_id', true ),
			'target_url'     => esc_url_raw( get_post_meta( $post_id, 'dt_subscription_target_url', true ) ),
		);
	}

	/**
	 * Ensure user has permissions to create a subscription.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @since  1.0
	 * @return true|\WP_Error True if the request has access to create items, \WP_Error object otherwise.
	 */
	public function create_item_permissions_check( $request ) {
		$post_type = get_post_type_object( $this->post_type );

		if ( ! current_user_can( $post_type->cap->create_posts ) ) {
			return new \WP_Error( 'rest_cannot_create', esc_html__( 'Sorry, you are not allowed to create subscriptions.', 'distributor' ), array( 'status' => rest_authorization_required_code() ) );
		}

		return true;
	}

	/**
	 * Create a subscription
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @since  1.0
	 * @return WP_REST_Response|\WP_Error Response object on success, or \WP_Error object on failure.
	 */
	public function create_item( $request ) {
		if ( ! empty( $request['id'] ) ) {
			return new \WP_Error( 'rest_subscription_exists', esc_html__( 'Cannot create existing subscription.', 'distributor' ), array( 'status' => 400 ) );
		}

		if ( empty( $request['post_id'] ) ) {
			return new \WP_Error( 'rest_subscription_post_missing', esc_html__( 'Subscription post does not exist.', 'distributor' ), array( 'status' => 400 ) );
		}

		$post_id = \Distributor\Subscriptions\create_subscription( $request['post_id'], $request['remote_post_id'], $request['target_url'], $request['signature'] );

		/**
		 * We need to make sure this post shows up as "distributed"
		 */
		$connection_map = get_post_meta( $request['post_id'], 'dt_connection_map', true );

		if ( empty( $connection_map ) ) {
			$connection_map = [
				'internal' => [],
				'external' => [],
			];
		}

		if ( empty( $connection_map['external'] ) ) {
			$connection_map['external'] = [];
		}

		/**
		 * We don't know the external connection ID
		 *
		 * @Todo: Find a way around this
		 */
		$connection_map['external'][-1] = [
			'post_id' => (int) $request['remote_post_id'],
			'time'    => time(),
		];

		update_post_meta( $request['post_id'], 'dt_connection_map', $connection_map );

		$response = rest_ensure_response( $this->get_response_array( $post_id ) );

		$response->set_status( 201 );

		return $response;
	}


	/**
	 * Ensure user has permissions to delete a subscription
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @since  1.0
	 * @return true|\WP_Error True if the request has access to delete the item, \WP_Error object otherwise.
	 */
	public function delete_item_permissions_check( $request ) {
		$post = get_post( $request['post_id'] );

		if ( empty( $post ) ) {
			return new \WP_Error( 'rest_post_invalid_id', esc_html__( 'Invalid post ID.', 'distributor' ), array( 'status' => 404 ) );
		}

		$subscriptions = get_post_meta( $request['post_id'], 'dt_subscriptions', true );

		if ( empty( $subscriptions[ md5( $request['signature'] ) ] ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Delete a subscription
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @since  1.0
	 * @return WP_REST_Response|\WP_Error Response object on success, or \WP_Error object on failure.
	 */
	public function delete_item( $request ) {
		$post = get_post( $request['post_id'] );

		if ( empty( $post ) ) {
			return new \WP_Error( 'rest_post_invalid_id', esc_html__( 'Invalid post ID.', 'distributor' ), array( 'status' => 404 ) );
		}

		\Distributor\Subscriptions\delete_subscription( $request['post_id'], $request['signature'] );

		$response = new \WP_REST_Response();
		$response->set_data( array( 'deleted' => true ) );

		return $response;
	}
}