*/ class Routeapp_Webhooks { const API_GATEWAY_ENDPOINT = 'https://distributed-api.route.com'; const API_GATEWAY_ENDPOINT_STAGING = 'https://distributed-api-stage.route.com'; const WEBHOOK_USER_OPTION = '_routeapp_webhook_user_id'; const webhooksList = [ // order.updated will trigger both order update and order create events 'RouteApp Order Upsert' => [ 'name' => 'RouteApp Order Upsert', 'status' => 'active', 'topic' => 'order.updated', 'delivery_url' => '/webhooks/', 'update' => false, ], 'RouteApp Order Deleted' => [ 'name' => 'RouteApp Order Deleted', 'status' => 'active', 'topic' => 'order.deleted', 'delivery_url' => '/webhooks/', 'update' => false, ], ]; private $_webhooksList; public function __construct() { $this->buildWebhookList(); } private function buildWebhookList() { $webhooksList = self::webhooksList; foreach ($webhooksList as $webhookName => $values) { $webhooksList[$webhookName]['delivery_url'] = $this->generate_delivery_url($values['delivery_url']); } $this->_webhooksList = $webhooksList; } private function generate_delivery_url($url) { $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT'); if (is_null($custom_env) || !$custom_env) { $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : ''; } $delivery_url = $custom_env == 'stage' ? self::API_GATEWAY_ENDPOINT_STAGING : self::API_GATEWAY_ENDPOINT; $delivery_url.= $url . get_option('routeapp_merchant_id'); return $delivery_url; } private function generate_default_secret() { $merchant_id = get_option('routeapp_merchant_id'); $secret_token = get_option('routeapp_secret_token'); $string = $merchant_id . '-' . $secret_token; return hash('sha256', $string); } public function set_secret() { $secret = $this->generate_default_secret(); update_option( '_routeapp_webhooks_secret', $secret); return $secret; } public function get_secret() { $secret = get_option('_routeapp_webhooks_secret', false); if (!$secret) { $secret = $this->set_secret(); } return $secret; } private function _is_routedata_complete() { $merchant_id = get_option('routeapp_merchant_id', false); $secret_token = get_option('routeapp_secret_token', false); return $merchant_id && $secret_token; } /** * Whether this WP user can authorize WooCommerce order webhook payloads. * * WooCommerce delivers order.updated by making an internal REST request as * the webhook user_id. user_id 0 returns woocommerce_rest_cannot_view (401). * * @param int $user_id WordPress user ID. * @return bool */ public static function user_can_deliver_webhooks($user_id) { $user_id = (int) $user_id; if ($user_id < 1) { return false; } if (!function_exists('user_can')) { return false; } return user_can($user_id, 'read_shop_order') || user_can($user_id, 'manage_woocommerce'); } /** * Resolve a user_id for webhook delivery. Never returns a logged-out (0) user * when an administrator or shop manager exists. * * @return int */ public static function get_webhook_user_id() { $current = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; if (self::user_can_deliver_webhooks($current)) { update_option(self::WEBHOOK_USER_OPTION, $current); return $current; } $stored = (int) get_option(self::WEBHOOK_USER_OPTION, 0); if (self::user_can_deliver_webhooks($stored)) { return $stored; } $resolved = self::find_webhook_user_id(); if ($resolved) { update_option(self::WEBHOOK_USER_OPTION, $resolved); } return $resolved; } /** * First administrator, then shop manager. * * @return int */ public static function find_webhook_user_id() { if (!function_exists('get_users')) { return 0; } foreach (array('administrator', 'shop_manager') as $role) { $users = get_users(array( 'role' => $role, 'number' => 1, 'fields' => 'ID', 'orderby' => 'ID', 'order' => 'ASC', )); if (!empty($users)) { $id = (int) $users[0]; if ($id > 0) { return $id; } } } return 0; } /** * Whether $webhook has the methods upsert/score need. Filters stray false * from wc_get_webhook() that a truthy check can miss (e.g. unexpected objects). * * @param mixed $webhook Candidate. * @param bool $need_name Also require get_name(). * @return bool */ public static function is_webhook_like($webhook, $need_name = false) { if (!is_object($webhook)) { return false; } $methods = array('get_id', 'get_user_id', 'get_status'); if ($need_name) { $methods[] = 'get_name'; } foreach ($methods as $method) { if (!method_exists($webhook, $method) || !is_callable(array($webhook, $method))) { return false; } } return true; } /** * Prefer a webhook that can actually deliver (valid user, active), then oldest ID. * * @param array $webhooks WC_Webhook objects. * @return object|null */ public static function pick_keeper_webhook($webhooks) { if (!is_array($webhooks) || empty($webhooks)) { return null; } $webhooks = array_values(array_filter($webhooks, array('Routeapp_Webhooks', 'is_webhook_like'))); if (empty($webhooks)) { return null; } usort($webhooks, function ($a, $b) { $score_a = self::score_webhook($a); $score_b = self::score_webhook($b); if ($score_a !== $score_b) { return ($score_b > $score_a) ? 1 : -1; } $id_a = (int) $a->get_id(); $id_b = (int) $b->get_id(); if ($id_a === $id_b) { return 0; } return ($id_a < $id_b) ? -1 : 1; }); return $webhooks[0]; } /** * @param mixed $webhook Webhook-like object. * @return int */ public static function score_webhook($webhook) { if (!self::is_webhook_like($webhook)) { return 0; } $score = 0; if ((int) $webhook->get_user_id() > 0) { $score += 4; } if ('active' === $webhook->get_status()) { $score += 2; } return $score; } public function upsert_webhooks() { //rebuild webhooks list to avoid issues with merchant_id on construct $this->buildWebhookList(); if ( class_exists( 'WC_Data_Store' ) && $this->_is_routedata_complete() ) { $secret = $this->get_secret(); $user_id = self::get_webhook_user_id(); $grouped = $this->get_existing_route_webhooks(); $missing = array(); foreach ($this->_webhooksList as $name => $values) { $existing = isset($grouped[$name]) ? $grouped[$name] : array(); if (empty($existing)) { if ($user_id) { $this->_create_webhook($values, $user_id, $secret); } else { $missing[] = $name; } continue; } $keeper = self::pick_keeper_webhook($existing); if (!$keeper) { if ($user_id) { $this->_create_webhook($values, $user_id, $secret); } else { $missing[] = $name; } continue; } foreach ($existing as $webhook) { if (!self::is_webhook_like($webhook)) { continue; } if ((int) $webhook->get_id() !== (int) $keeper->get_id()) { $this->_remove_webhook($webhook->get_id()); } } // Paused is a manual opt-out: quotes stay on, order posting stays off. // If any copy is paused, the survivor stays paused so an active // duplicate cannot keep delivering. Disabled is WooCommerce's // automatic status after delivery failures, and we still heal that. if (self::group_has_paused_webhook($existing)) { $values['status'] = 'paused'; } if (!$this->sync_webhook($keeper, $values, $secret, $user_id)) { $missing[] = $name; } } self::finalize_webhooks_created_flag($missing); } } /** * Mark webhooks created only when every required hook exists. * Leaving the flag unset lets admin_init retry on the next logged-in request. * * @param array $missing_names Names that could not be created (no delivery user). * @return bool True if the created flag was set. */ public static function finalize_webhooks_created_flag($missing_names) { if (!empty($missing_names)) { self::log_webhook_upsert( 'did not mark webhooks created; no WordPress user can authorize order payloads. Missing: ' . implode(', ', $missing_names) ); if (function_exists('delete_option')) { delete_option('_routeapp_webhooks_created'); } return false; } update_option('_routeapp_webhooks_created', 1); return true; } /** * @param string $message Log line. * @return void */ public static function log_webhook_upsert($message) { $line = '[routeapp] ' . $message; if (function_exists('wc_get_logger')) { $logger = wc_get_logger(); if (is_object($logger) && method_exists($logger, 'warning')) { $logger->warning($line, array('source' => 'routeapp')); return; } } if (defined('PHPUNIT_COMPOSER_INSTALL')) { return; } if (function_exists('error_log')) { error_log($line); } } /** * All Route webhooks, including disabled duplicates. Woo search_webhooks() * defaults to limit 10, which missed existing hooks and created more. * * @return array */ private function get_existing_route_webhooks() { $grouped = array(); $data_store = \WC_Data_Store::load('webhook'); $ids = $data_store->search_webhooks(array('limit' => -1)); if (!is_array($ids)) { return $grouped; } foreach ($ids as $id) { $webhook = wc_get_webhook($id); if (!self::is_webhook_like($webhook, true)) { continue; } $name = $webhook->get_name(); if (!array_key_exists($name, $this->_webhooksList)) { continue; } $grouped[$name][] = $webhook; } return $grouped; } /** * User that can authorize this webhook's payloads, or 0. * Logs when neither the existing webhook user nor $user_id is valid. * * @param object $webhook Webhook-like object. * @param int $user_id Resolved delivery user. * @return int */ public static function resolve_effective_webhook_user($webhook, $user_id) { $existing_user = (int) $webhook->get_user_id(); if (self::user_can_deliver_webhooks($existing_user)) { return $existing_user; } $user_id = (int) $user_id; if (self::user_can_deliver_webhooks($user_id)) { return $user_id; } $id = method_exists($webhook, 'get_id') ? $webhook->get_id() : 0; $name = method_exists($webhook, 'get_name') ? $webhook->get_name() : ''; self::log_webhook_upsert( 'cannot activate webhook id=' . $id . ' name="' . $name . '"; no WordPress user can authorize order payloads (existing user_id=' . $existing_user . ')' ); return 0; } /** * Whether the merchant paused any copy of this webhook. * * @param array $webhooks Webhook-like objects for one name. * @return bool */ private static function group_has_paused_webhook($webhooks) { foreach ($webhooks as $webhook) { if (self::is_webhook_like($webhook) && 'paused' === $webhook->get_status()) { return true; } } return false; } /** * Update in place. Do not delete+recreate (that is what duplicated hooks). * * @param object $webhook WC_Webhook. * @param array $values Expected webhook config. * @param string $secret Delivery secret. * @param int $user_id Resolved delivery user. * @return bool True when a valid delivery user is in place. */ private function sync_webhook($webhook, $values, $secret, $user_id) { $changed = false; $existing_user = (int) $webhook->get_user_id(); $effective_user = self::resolve_effective_webhook_user($webhook, $user_id); if ($effective_user && $effective_user !== $existing_user) { $webhook->set_user_id($effective_user); $changed = true; } $current_status = $webhook->get_status(); if ($effective_user && 'paused' !== $current_status && $current_status !== $values['status']) { $webhook->set_status($values['status']); $changed = true; if ('active' === $values['status'] && method_exists($webhook, 'set_failure_count')) { $webhook->set_failure_count(0); } } if ($webhook->get_topic() !== $values['topic']) { $webhook->set_topic($values['topic']); $changed = true; } if ($webhook->get_delivery_url() !== $values['delivery_url']) { $webhook->set_delivery_url($values['delivery_url']); $changed = true; } if ($webhook->get_secret() !== $secret) { $webhook->set_secret($secret); $changed = true; } if ($changed) { $webhook->save(); } return $effective_user > 0; } private function _create_webhook($webhookData, $user_id, $secret) { $user_id = (int) $user_id; if ($user_id < 1) { return; } $webhook = new \WC_Webhook(); $webhook->set_name($webhookData['name']); $webhook->set_user_id($user_id); $webhook->set_topic( $webhookData['topic'] ); // Event used to trigger a webhook. $webhook->set_secret( $secret ); // Secret to validate webhook when received. $webhook->set_delivery_url( $webhookData['delivery_url'] ); // URL where webhook should be sent. $webhook->set_status( $webhookData['status'] ); // Webhook status. $webhook->save(); } /** * Permanently delete a duplicate Route webhook. * * Force-delete is required: we are collapsing failed copies, not moving one * to trash. A soft delete would leave disabled rows that search_webhooks() * would pick up again and recreate the duplication loop. WooCommerce webhook * storage (wc_webhooks table) also has no trash restore path. * * @param int $hookId Webhook ID. * @return void */ private function _remove_webhook($hookId) { $wh = function_exists('wc_get_webhook') ? wc_get_webhook($hookId) : false; if (!$wh) { $wh = new \WC_Webhook(); $wh->set_id($hookId); } $wh->delete(true); } }