File "class-wck-api.php"
Full Path: /home/ycoalition/public_html/blog/wp-admin/js/widgets/plugins/klaviyo/includes/class-wck-api.php
File size: 19.06 KB
MIME-type: text/x-php
Charset: utf-8
<?php
/**
* WooCommerceKlaviyo API
*
* Handles WCK-API endpoint requests
*
* @package WooCommerceKlaviyo/API
* @since 2.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* WCK_API class.
*/
class WCK_API {
const VERSION = '3.3.3';
const KLAVIYO_BASE_URL = 'klaviyo/v1';
const ORDERS_ENDPOINT = 'orders';
const EXTENSION_VERSION_ENDPOINT = 'version';
const PRODUCTS_ENDPOINT = 'products';
const OPTIONS_ENDPOINT = 'options';
const DISABLE_ENDPOINT = 'disable';
// API RESPONSES.
const API_RESPONSE_CODE = 'status_code';
const API_RESPONSE_ERROR = 'error';
const API_RESPONSE_REASON = 'reason';
const API_RESPONSE_SUCCESS = 'success';
// HTTP CODES.
const STATUS_CODE_HTTP_OK = 200;
const STATUS_CODE_NO_CONTENT = 204;
const STATUS_CODE_BAD_REQUEST = 400;
const STATUS_CODE_AUTHENTICATION_ERROR = 401;
const STATUS_CODE_AUTHORIZATION_ERROR = 403;
const STATUS_CODE_INTERNAL_SERVER_ERROR = 500;
const DEFAULT_RECORDS_PER_PAGE = '50';
const DATE_MODIFIED = 'post_modified_gmt';
const POST_STATUS_ANY = 'any';
const ERROR_KEYS_NOT_PASSED = 'consumer key or consumer secret not passed';
const ERROR_CONSUMER_KEY_NOT_FOUND = 'consumer_key not found';
const PERMISSION_READ = 'read';
const PERMISSION_WRITE = 'write';
const PERMISSION_READ_WRITE = 'read_write';
const PERMISSION_METHOD_MAP = array(
self::PERMISSION_READ => array( 'GET' ),
self::PERMISSION_WRITE => array( 'POST' ),
self::PERMISSION_READ_WRITE => array( 'GET', 'POST' ),
);
/**
* Check if there is a new version of the Klaviyo plugin available for download. WordPress stores this info in the
* database as an object with the following properties:
* - last_checked (int) Unix timestamp when request was last made to WordPress server.
* - response (array) Contains plugin data with updates available stored by key e.g.'klaviyo/klaviyo.php'.
* - no_update (array) Contains plugin data for plugins without updates stored by key e.g. 'klaviyo/klaviyo.php'.
* - translations (array) Not relevant to us here.
*
* The response and no_update arrays are mutually exclusive so we can see if Klaviyo's plugin has been checked for
* and if it's in the response array.
*
* See wp_update_plugins function in `wordpress/wp-includes/update.php` for more information on how this is set.
*
* @param stdClass $plugins_transient Optional arg if the transient value is already in scope e.g. during update check.
* @return bool
*/
public static function is_most_recent_version( stdClass $plugins_transient = null ) {
if ( ! $plugins_transient ) {
$plugins_transient = get_site_transient( 'update_plugins' );
}
// True if response property isn't set, we don't want to alert on a false positive here.
if ( ! isset( $plugins_transient->response ) ) {
return true;
}
// True if Klaviyo plugin is not in the response array meaning no update available.
return ! array_key_exists( KLAVIYO_BASENAME, $plugins_transient->response );
}
/**
* Build payload for version endpoint and webhooks.
*
* @param bool $is_updating Short circuit checking version if plugin is being updated, we know it's most recent.
* @return array
*/
public static function build_version_payload( $is_updating = false ) {
return array(
'plugin_version' => self::VERSION,
'is_most_recent_version' => $is_updating || self::is_most_recent_version(),
);
}
}
/**
* Iterate over query to fetch all resource IDs.
*
* @param WP_Query $loop The query object.
* @return array
*/
function count_loop( WP_Query $loop ) {
$loop_ids = array();
while ( $loop->have_posts() ) {
$loop->the_post();
$loop_id = get_the_ID();
array_push( $loop_ids, $loop_id );
}
return $loop_ids;
}
/**
* Legacy validation function.
*
* @param WP_REST_Request $request Incoming request object.
* @return array
*/
function validate_request( $request ) {
$consumer_key = $request->get_param( 'consumer_key' );
$consumer_secret = $request->get_param( 'consumer_secret' );
if ( empty( $consumer_key ) || empty( $consumer_secret ) ) {
return validation_response(
true,
WCK_API::STATUS_CODE_BAD_REQUEST,
WCK_API::ERROR_KEYS_NOT_PASSED,
false
);
}
global $wpdb;
// this is stored as a hash so we need to query on the hash.
$key = hash_hmac( 'sha256', $consumer_key, 'wc-api' );
$user = $wpdb->get_row(
$wpdb->prepare(
"
SELECT consumer_key, consumer_secret
FROM {$wpdb->prefix}woocommerce_api_keys
WHERE consumer_key = %s
",
$key
)
);
if ( $user->consumer_secret === $consumer_secret ) {
return validation_response(
false,
WCK_API::STATUS_CODE_HTTP_OK,
null,
true
);
}
return validation_response(
true,
WCK_API::STATUS_CODE_AUTHORIZATION_ERROR,
WCK_API::ERROR_CONSUMER_KEY_NOT_FOUND,
false
);
}
/**
* Validate incoming requests to custom endpoints.
*
* @param WP_REST_Request $request Incoming request object.
* @return bool|WP_Error True if validation succeeds, otherwise WP_Error to be handled by rest server.
*/
function validate_request_v2( WP_REST_Request $request ) {
$consumer_key = $request->get_param( 'consumer_key' );
$consumer_secret = $request->get_param( 'consumer_secret' );
if ( empty( $consumer_key ) || empty( $consumer_secret ) ) {
return new WP_Error(
'klaviyo_missing_key_secret',
'One or more of consumer key and secret are missing.',
array( 'status' => WCK_API::STATUS_CODE_AUTHENTICATION_ERROR )
);
}
global $wpdb;
// this is stored as a hash so we need to query on the hash.
$key = hash_hmac( 'sha256', $consumer_key, 'wc-api' );
$user = $wpdb->get_row(
$wpdb->prepare(
"
SELECT consumer_key, consumer_secret, permissions
FROM {$wpdb->prefix}woocommerce_api_keys
WHERE consumer_key = %s
",
$key
)
);
// User query lookup on consumer key can return null or false.
if ( ! $user ) {
return new WP_Error(
'klaviyo_cannot_authentication',
'Cannot authenticate with provided credentials.',
array( 'status' => 401 )
);
}
// User does not have proper permissions.
if ( ! in_array( $request->get_method(), WCK_API::PERMISSION_METHOD_MAP[ $user->permissions ], true ) ) {
return new WP_Error(
'klaviyo_improper_permissions',
'Improper permissions to access this resource.',
array( 'status' => WCK_API::STATUS_CODE_AUTHORIZATION_ERROR )
);
}
// Success!
if ( $user->consumer_secret === $consumer_secret ) {
return true;
}
// Consumer secret didn't match or some other issue authenticating.
return new WP_Error(
'klaviyo_invalid_authentication',
'Invalid authentication.',
array( 'status' => WCK_API::STATUS_CODE_AUTHENTICATION_ERROR )
);
}
/**
* Helper method to build response.
*
* @param boolean $error Whether there's an error during validation.
* @param string $code HTTP status code.
* @param string $reason Reason for error.
* @param boolean $success Whether validation was successful.
* @return array
*/
function validation_response( $error, $code, $reason, $success ) {
return array(
WCK_API::API_RESPONSE_ERROR => $error,
WCK_API::API_RESPONSE_CODE => $code,
WCK_API::API_RESPONSE_REASON => $reason,
WCK_API::API_RESPONSE_SUCCESS => $success,
);
}
/**
* Helper function for
*
* @param WP_REST_Request $request Incoming request object.
* @param string $post_type WordPress post type.
* @return array
*/
function process_resource_args( $request, $post_type ) {
$page_limit = $request->get_param( 'page_limit' );
if ( empty( $page_limit ) ) {
$page_limit = WCK_API::DEFAULT_RECORDS_PER_PAGE;
}
$date_modified_after = $request->get_param( 'date_modified_after' );
$date_modified_before = $request->get_param( 'date_modified_before' );
$page = $request->get_param( 'page' );
$args = array(
'post_type' => $post_type,
'posts_per_page' => $page_limit,
'post_status' => WCK_API::POST_STATUS_ANY,
'paged' => $page,
'date_query' => array(
array(
'column' => WCK_API::DATE_MODIFIED,
'after' => $date_modified_after,
'before' => $date_modified_before,
),
),
);
return $args;
}
/**
* Helper function to build arg value for date_modified query.
*
* To maintain backwards compatibility we need to convert the
* datetime string (e.g. 2023-06-01T17:01:29) to unix timestamp
* because dates are not fine-grained enough for periodic syncs.
* https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date
*
* Date query arg value passed to wc_get_orders can be null.
*
* @param WP_REST_Request $request Incoming request object.
* @return string|null
*/
function kl_build_date_modified_arg_value( WP_REST_Request $request ) {
$date_modified_after = $request->get_param( 'date_modified_after' );
$date_modified_before = $request->get_param( 'date_modified_before' );
// strtotime() returns false if it cannot parse datetime string.
$after_ts = strtotime( $date_modified_after );
$before_ts = strtotime( $date_modified_before );
if ( $after_ts && $before_ts ) {
return "{$after_ts}...{$before_ts}";
} elseif ( $after_ts ) {
return ">={$after_ts}";
} elseif ( $before_ts ) {
return "<={$before_ts}";
}
return null;
}
/**
* Get orders based on request params.
*
* @param WP_REST_Request $request Incoming request object.
* @return array
*/
function kl_get_orders_count( WP_REST_Request $request ) {
$orders = kl_query_orders( $request );
return array( 'order_count' => $orders->total );
}
/**
* Get product count based on request params.
*
* @param WP_REST_Request $request Incoming request object.
* @return array
*/
function get_products_count( WP_REST_Request $request ) {
$validated_request = validate_request( $request );
if ( true === $validated_request['error'] ) {
return $validated_request;
}
$args = process_resource_args( $request, 'product' );
$loop = new WP_Query( $args );
$data = count_loop( $loop );
return array( 'product_count' => $loop->found_posts );
}
/**
* Get products based on request params.
*
* @param WP_REST_Request $request Incoming request object.
* @return array|array[]
*/
function get_products( WP_REST_Request $request ) {
$validated_request = validate_request( $request );
if ( true === $validated_request['error'] ) {
return $validated_request;
}
$args = process_resource_args( $request, 'product' );
$loop = new WP_Query( $args );
$data = count_loop( $loop );
return array( 'product_ids' => $data );
}
/**
* Query for orders using request params.
*
* `wc_get_orders` is an HPOS compatible query method that is backwards
* compatible with the old wp_posts table as well. Passing `paginate`
* as an arg returns an object instead of just an array with result values.
*
* e.g.
* stdClass Object
* (
* [orders] => Array(
* [0] => 157
* [1] => 156
* )
* [total] => 51
* [max_num_pages] => 26
* )
*
* @param WP_REST_Request $request Incoming request object.
* @return stdClass|WC_Order[]
*/
function kl_query_orders( $request ) {
$page = $request->get_param( 'page' );
$page_limit = $request->get_param( 'page_limit' );
if ( empty( $page_limit ) ) {
$page_limit = WCK_API::DEFAULT_RECORDS_PER_PAGE;
}
$date_modified_arg_value = kl_build_date_modified_arg_value( $request );
$args = array(
'type' => 'shop_order',
'limit' => $page_limit,
'paged' => $page,
'date_modified' => $date_modified_arg_value,
'return' => 'ids',
'paginate' => true,
);
return wc_get_orders( $args );
}
/**
* Get orders count based on request params.
*
* @param WP_REST_Request $request Incoming request object.
* @return array
*/
function kl_get_orders( WP_REST_Request $request ) {
$orders = kl_query_orders( $request );
return array( 'order_ids' => $orders->orders );
}
/**
* Handle GET request to /klaviyo/v1/version. Returns the current version and if
* the installed version is the most recent available in the plugin directory.
*
* @return array
*/
function get_extension_version() {
return WCK_API::build_version_payload();
}
/**
* Handle POST request to /klaviyo/v1/options and update plugin options.
*
* @param WP_REST_Request $request Incoming request object.
* @return bool|mixed|void|WP_Error
*/
function update_options( WP_REST_Request $request ) {
$body = json_decode( $request->get_body(), $assoc = true );
if ( ! $body ) {
return new WP_Error(
'klaviyo_empty_body',
'Body of request cannot be empty.',
array( 'status' => 400 )
);
}
$options = get_option( 'klaviyo_settings' );
if ( ! $options ) {
$options = array();
}
$updated_options = array_replace( $options, $body );
$is_update = (bool) array_diff_assoc( $options, $updated_options );
// If there is no change between existing and new settings `update_option` returns false. Want to distinguish
// between that scenario and an actual problem when updating the plugin options.
if ( ! update_option( 'klaviyo_settings', $updated_options ) && $is_update ) {
return new WP_Error(
'klaviyo_update_failed',
'Options update failed.',
array(
'status' => WCK_API::STATUS_CODE_INTERNAL_SERVER_ERROR,
'options' => get_option( 'klaviyo_settings' ),
)
);
}
// Return plugin version info so this can be saved in Klaviyo when setting up integration for the first time.
return array_merge( $updated_options, WCK_API::build_version_payload() );
}
/**
* Handle GET request to /klaviyo/v1/options and return options set for plugin.
*
* @return array Klaviyo plugin options.
*/
function get_klaviyo_options() {
return get_option( 'klaviyo_settings' );
}
/**
* Handle POST request to /klaviyo/v1/disable by deactivating the plugin.
*
* @param WP_REST_Request $request Incoming request object.
* @return WP_Error|WP_REST_Response
*/
function wck_disable_plugin( WP_REST_Request $request ) {
$body = json_decode( $request->get_body(), $assoc = true );
// Verify body contains required data.
if ( ! isset( $body['klaviyo_public_api_key'] ) ) {
return new WP_Error(
'klaviyo_disable_failed',
'Disable plugin failed, \'klaviyo_public_api_key\' missing from body.',
array( 'status' => WCK_API::STATUS_CODE_BAD_REQUEST )
);
}
// Verify keys match if set in WordPress options table.
$public_api_key = WCK()->options->get_klaviyo_option( 'klaviyo_public_api_key' );
if ( $public_api_key && $body['klaviyo_public_api_key'] !== $public_api_key ) {
return new WP_Error(
'klaviyo_disable_failed',
'Disable plugin failed, \'klaviyo_public_api_key\' does not match key set in WP options.',
array( 'status' => WCK_API::STATUS_CODE_BAD_REQUEST )
);
}
WCK()->installer->deactivate_klaviyo();
return new WP_REST_Response( null, WCK_API::STATUS_CODE_NO_CONTENT );
}
add_action(
'rest_api_init',
function () {
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
WCK_API::EXTENSION_VERSION_ENDPOINT,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'get_extension_version',
'permission_callback' => '__return_true',
)
);
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
'orders/count',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'kl_get_orders_count',
'permission_callback' => 'validate_request_v2',
)
);
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
'products/count',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'get_products_count',
'permission_callback' => '__return_true',
)
);
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
WCK_API::ORDERS_ENDPOINT,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'kl_get_orders',
'args' => array(
'id' => array(
'validate_callback' => 'is_numeric',
),
),
'permission_callback' => 'validate_request_v2',
)
);
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
WCK_API::PRODUCTS_ENDPOINT,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'get_products',
'args' => array(
'id' => array(
'validate_callback' => 'is_numeric',
),
),
'permission_callback' => '__return_true',
)
);
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
WCK_API::OPTIONS_ENDPOINT,
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'update_options',
'permission_callback' => 'validate_request_v2',
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'get_klaviyo_options',
'permission_callback' => 'validate_request_v2',
),
)
);
register_rest_route(
WCK_API::KLAVIYO_BASE_URL,
WCK_API::DISABLE_ENDPOINT,
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'wck_disable_plugin',
'permission_callback' => 'validate_request_v2',
),
)
);
}
);
/**
* Check if there is a new version of the Klaviyo plugin. Return early if we've already identified that we are out of
* date. This is stored in a transient that does not expire. This transient is created here the first time we identify
* the plugin is out of date and send that information to Klaviyo. It's deleted when someone updates the Klaviyo plugin.
*
* @param stdClass $transient_value The transient value to be saved, this is an object with various lists of plugins and their data.
*/
function klaviyo_check_for_plugin_update( $transient_value ) {
// If we're up to date or we've already sent this information along just return early.
if ( WCK_API::is_most_recent_version( $transient_value ) || get_site_transient( 'is_klaviyo_plugin_outdated' ) ) {
return;
}
// Send options payload to Klaviyo.
WCK()->webhook_service->send_options_webhook();
// Set site transient so we don't keep making requests.
set_site_transient( 'is_klaviyo_plugin_outdated', 1 );
}
/**
* This fires when the 'update_plugins' transient is updated which occurs when WordPress polls the plugin directory api
* to check for plugin updates. The wp_update_plugins cron runs every 12 hours but requires a pageload for the cron
* check to fire. More information at: https://developer.wordpress.org/plugins/cron/ and https://developer.wordpress.org/reference/functions/wp_update_plugins/
*
* We hook into this to see if there's a new version of the Klaviyo plugin available, if
* so we want to send this information to the corresponding Klaviyo account. Only do so
* if we haven't already sent this information to Klaviyo.
*/
if ( ! get_site_transient( 'is_klaviyo_plugin_outdated' ) ) {
add_action( 'set_site_transient_update_plugins', 'klaviyo_check_for_plugin_update' );
}
function kl_get_plugin_usage_meta_data() {
if ( class_exists( 'WooCommerce' ) ) {
global $woocommerce;
$woocommerce_version = "woocommerce/$woocommerce->version";
} else {
$woocommerce_version = '';
}
$wp_version = get_bloginfo('version');
$php_version = PHP_VERSION;
$klaviyo_plugin_version = WCK_API::VERSION;
return "woocommerce-klaviyo/$klaviyo_plugin_version wordpress/$wp_version php/$php_version $woocommerce_version";
}