| @@ -11,8 +11,9 @@ | ||
| 11 | 11 | class Routeapp_Webhooks { |
| 12 | 12 | |
| 13 | 13 | const API_GATEWAY_ENDPOINT = 'https://distributed-api.route.com'; |
| 14 | 14 | const API_GATEWAY_ENDPOINT_STAGING = 'https://distributed-api-stage.route.com'; |
| 15 | + const WEBHOOK_USER_OPTION = '_routeapp_webhook_user_id'; | |
| 15 | 16 | |
| 16 | 17 | const webhooksList = [ |
| 17 | 18 | // order.updated will trigger both order update and order create events |
| 18 | 19 | 'RouteApp Order Upsert' => [ |
| @@ -83,68 +84,432 @@ | ||
| 83 | 84 | $secret_token = get_option('routeapp_secret_token', false); |
| 84 | 85 | return $merchant_id && $secret_token; |
| 85 | 86 | } |
| 86 | 87 | |
| 88 | + /** | |
| 89 | + * Whether this WP user can authorize WooCommerce order webhook payloads. | |
| 90 | + * | |
| 91 | + * WooCommerce delivers order.updated by making an internal REST request as | |
| 92 | + * the webhook user_id. user_id 0 returns woocommerce_rest_cannot_view (401). | |
| 93 | + * | |
| 94 | + * @param int $user_id WordPress user ID. | |
| 95 | + * @return bool | |
| 96 | + */ | |
| 97 | + public static function user_can_deliver_webhooks($user_id) { | |
| 98 | + $user_id = (int) $user_id; | |
| 99 | + if ($user_id < 1) { | |
| 100 | + return false; | |
| 101 | + } | |
| 102 | + if (!function_exists('user_can')) { | |
| 103 | + return false; | |
| 104 | + } | |
| 105 | + return user_can($user_id, 'read_shop_order') || user_can($user_id, 'manage_woocommerce'); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** | |
| 109 | + * Resolve a user_id for webhook delivery. Never returns a logged-out (0) user | |
| 110 | + * when an administrator or shop manager exists. | |
| 111 | + * | |
| 112 | + * @return int | |
| 113 | + */ | |
| 114 | + public static function get_webhook_user_id() { | |
| 115 | + $current = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; | |
| 116 | + if (self::user_can_deliver_webhooks($current)) { | |
| 117 | + update_option(self::WEBHOOK_USER_OPTION, $current); | |
| 118 | + return $current; | |
| 119 | + } | |
| 120 | + | |
| 121 | + $stored = (int) get_option(self::WEBHOOK_USER_OPTION, 0); | |
| 122 | + if (self::user_can_deliver_webhooks($stored)) { | |
| 123 | + return $stored; | |
| 124 | + } | |
| 125 | + | |
| 126 | + $resolved = self::find_webhook_user_id(); | |
| 127 | + if ($resolved) { | |
| 128 | + update_option(self::WEBHOOK_USER_OPTION, $resolved); | |
| 129 | + } | |
| 130 | + return $resolved; | |
| 131 | + } | |
| 132 | + | |
| 133 | + /** | |
| 134 | + * First administrator, then shop manager. | |
| 135 | + * | |
| 136 | + * @return int | |
| 137 | + */ | |
| 138 | + public static function find_webhook_user_id() { | |
| 139 | + if (!function_exists('get_users')) { | |
| 140 | + return 0; | |
| 141 | + } | |
| 142 | + foreach (array('administrator', 'shop_manager') as $role) { | |
| 143 | + $users = get_users(array( | |
| 144 | + 'role' => $role, | |
| 145 | + 'number' => 1, | |
| 146 | + 'fields' => 'ID', | |
| 147 | + 'orderby' => 'ID', | |
| 148 | + 'order' => 'ASC', | |
| 149 | + )); | |
| 150 | + if (!empty($users)) { | |
| 151 | + $id = (int) $users[0]; | |
| 152 | + if ($id > 0) { | |
| 153 | + return $id; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + return 0; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * Whether $webhook has the methods upsert/score need. Filters stray false | |
| 162 | + * from wc_get_webhook() that a truthy check can miss (e.g. unexpected objects). | |
| 163 | + * | |
| 164 | + * @param mixed $webhook Candidate. | |
| 165 | + * @param bool $need_name Also require get_name(). | |
| 166 | + * @return bool | |
| 167 | + */ | |
| 168 | + public static function is_webhook_like($webhook, $need_name = false) { | |
| 169 | + if (!is_object($webhook)) { | |
| 170 | + return false; | |
| 171 | + } | |
| 172 | + $methods = array('get_id', 'get_user_id', 'get_status'); | |
| 173 | + if ($need_name) { | |
| 174 | + $methods[] = 'get_name'; | |
| 175 | + } | |
| 176 | + foreach ($methods as $method) { | |
| 177 | + if (!method_exists($webhook, $method) || !is_callable(array($webhook, $method))) { | |
| 178 | + return false; | |
| 179 | + } | |
| 180 | + } | |
| 181 | + return true; | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * Prefer a webhook that can actually deliver (valid user, active), then oldest ID. | |
| 186 | + * | |
| 187 | + * @param array $webhooks WC_Webhook objects. | |
| 188 | + * @return object|null | |
| 189 | + */ | |
| 190 | + public static function pick_keeper_webhook($webhooks) { | |
| 191 | + if (!is_array($webhooks) || empty($webhooks)) { | |
| 192 | + return null; | |
| 193 | + } | |
| 194 | + $webhooks = array_values(array_filter($webhooks, array('Routeapp_Webhooks', 'is_webhook_like'))); | |
| 195 | + if (empty($webhooks)) { | |
| 196 | + return null; | |
| 197 | + } | |
| 198 | + usort($webhooks, function ($a, $b) { | |
| 199 | + $score_a = self::score_webhook($a); | |
| 200 | + $score_b = self::score_webhook($b); | |
| 201 | + if ($score_a !== $score_b) { | |
| 202 | + return ($score_b > $score_a) ? 1 : -1; | |
| 203 | + } | |
| 204 | + $id_a = (int) $a->get_id(); | |
| 205 | + $id_b = (int) $b->get_id(); | |
| 206 | + if ($id_a === $id_b) { | |
| 207 | + return 0; | |
| 208 | + } | |
| 209 | + return ($id_a < $id_b) ? -1 : 1; | |
| 210 | + }); | |
| 211 | + return $webhooks[0]; | |
| 212 | + } | |
| 213 | + | |
| 214 | + /** | |
| 215 | + * @param mixed $webhook Webhook-like object. | |
| 216 | + * @return int | |
| 217 | + */ | |
| 218 | + public static function score_webhook($webhook) { | |
| 219 | + if (!self::is_webhook_like($webhook)) { | |
| 220 | + return 0; | |
| 221 | + } | |
| 222 | + $score = 0; | |
| 223 | + if ((int) $webhook->get_user_id() > 0) { | |
| 224 | + $score += 4; | |
| 225 | + } | |
| 226 | + if ('active' === $webhook->get_status()) { | |
| 227 | + $score += 2; | |
| 228 | + } | |
| 229 | + return $score; | |
| 230 | + } | |
| 231 | + | |
| 87 | 232 | public function upsert_webhooks() { |
| 88 | 233 | //rebuild webhooks list to avoid issues with merchant_id on construct |
| 89 | 234 | $this->buildWebhookList(); |
| 90 | 235 | |
| 91 | 236 | if ( class_exists( 'WC_Data_Store' ) && $this->_is_routedata_complete() ) { |
| 92 | - //check webhook secret on database | |
| 93 | 237 | $secret = $this->get_secret(); |
| 238 | + $user_id = self::get_webhook_user_id(); | |
| 239 | + $grouped = $this->get_existing_route_webhooks(); | |
| 94 | 240 | |
| 95 | - $data_store = \WC_Data_Store::load( 'webhook' ); | |
| 96 | - $webhooksIds = $data_store->search_webhooks(); | |
| 97 | - if (is_array($webhooksIds)) { | |
| 98 | - $webhooks = array_map( 'wc_get_webhook', $webhooksIds ); | |
| 99 | - //loop all existing webhooks | |
| 100 | - foreach ($webhooks as $webhook) { | |
| 101 | - if (array_key_exists($webhook->get_name(), $this->_webhooksList)) { | |
| 102 | - $this->_webhooksList[$webhook->get_name()]['id'] = $webhook->get_id(); | |
| 103 | - //check configuration | |
| 104 | - if ( | |
| 105 | - $this->_webhooksList[$webhook->get_name()]['status'] != $webhook->get_status() || | |
| 106 | - $this->_webhooksList[$webhook->get_name()]['topic'] != $webhook->get_topic() || | |
| 107 | - $this->_webhooksList[$webhook->get_name()]['delivery_url'] != $webhook->get_delivery_url() || | |
| 108 | - $secret != $webhook->get_secret() | |
| 109 | - ) { | |
| 110 | - // difference found, mark for update | |
| 111 | - $this->_webhooksList[$webhook->get_name()]['update'] = true; | |
| 112 | - } | |
| 241 | + $missing = array(); | |
| 242 | + foreach ($this->_webhooksList as $name => $values) { | |
| 243 | + $existing = isset($grouped[$name]) ? $grouped[$name] : array(); | |
| 244 | + if (empty($existing)) { | |
| 245 | + if ($user_id) { | |
| 246 | + $this->_create_webhook($values, $user_id, $secret); | |
| 247 | + } else { | |
| 248 | + $missing[] = $name; | |
| 113 | 249 | } |
| 250 | + continue; | |
| 114 | 251 | } |
| 115 | - } | |
| 116 | - //loop through the ones we need to add | |
| 117 | - foreach ($this->_webhooksList as $name => $values) { | |
| 118 | - if (isset($values['id'])) { | |
| 119 | - if ($values['update']) { | |
| 120 | - //remove actual webhook and re-create with the info we have | |
| 121 | - $this->_remove_webhook($values['id']); | |
| 122 | - $this->_create_webhook($values); | |
| 252 | + | |
| 253 | + $keeper = self::pick_keeper_webhook($existing); | |
| 254 | + if (!$keeper) { | |
| 255 | + if ($user_id) { | |
| 256 | + $this->_create_webhook($values, $user_id, $secret); | |
| 257 | + } else { | |
| 258 | + $missing[] = $name; | |
| 123 | 259 | } |
| 124 | - } else { | |
| 125 | - //create webhook | |
| 126 | - $this->_create_webhook($values); | |
| 260 | + continue; | |
| 127 | 261 | } |
| 262 | + foreach ($existing as $webhook) { | |
| 263 | + if (!self::is_webhook_like($webhook)) { | |
| 264 | + continue; | |
| 265 | + } | |
| 266 | + if ((int) $webhook->get_id() !== (int) $keeper->get_id()) { | |
| 267 | + $this->_remove_webhook($webhook->get_id()); | |
| 268 | + } | |
| 269 | + } | |
| 270 | + // Paused is a manual opt-out: quotes stay on, order posting stays off. | |
| 271 | + // If any copy is paused, the survivor stays paused so an active | |
| 272 | + // duplicate cannot keep delivering. Disabled is WooCommerce's | |
| 273 | + // automatic status after delivery failures, and we still heal that. | |
| 274 | + if (self::group_has_paused_webhook($existing)) { | |
| 275 | + $values['status'] = 'paused'; | |
| 276 | + } | |
| 277 | + if (!$this->sync_webhook($keeper, $values, $secret, $user_id)) { | |
| 278 | + $missing[] = $name; | |
| 279 | + } | |
| 128 | 280 | } |
| 129 | 281 | |
| 130 | - update_option( '_routeapp_webhooks_created', 1); | |
| 282 | + self::finalize_webhooks_created_flag($missing); | |
| 131 | 283 | } |
| 132 | 284 | } |
| 133 | 285 | |
| 134 | - private function _create_webhook($webhookData) { | |
| 286 | + /** | |
| 287 | + * Mark webhooks created only when every required hook exists. | |
| 288 | + * Leaving the flag unset lets admin_init retry on the next logged-in request. | |
| 289 | + * | |
| 290 | + * @param array $missing_names Names that could not be created (no delivery user). | |
| 291 | + * @return bool True if the created flag was set. | |
| 292 | + */ | |
| 293 | + public static function finalize_webhooks_created_flag($missing_names) { | |
| 294 | + if (!empty($missing_names)) { | |
| 295 | + self::log_webhook_upsert( | |
| 296 | + 'did not mark webhooks created; no WordPress user can authorize order payloads. Missing: ' . implode(', ', $missing_names) | |
| 297 | + ); | |
| 298 | + if (function_exists('delete_option')) { | |
| 299 | + delete_option('_routeapp_webhooks_created'); | |
| 300 | + } | |
| 301 | + return false; | |
| 302 | + } | |
| 303 | + update_option('_routeapp_webhooks_created', 1); | |
| 304 | + return true; | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** | |
| 308 | + * @param string $message Log line. | |
| 309 | + * @return void | |
| 310 | + */ | |
| 311 | + public static function log_webhook_upsert($message) { | |
| 312 | + $line = '[routeapp] ' . $message; | |
| 313 | + if (function_exists('wc_get_logger')) { | |
| 314 | + $logger = wc_get_logger(); | |
| 315 | + if (is_object($logger) && method_exists($logger, 'warning')) { | |
| 316 | + $logger->warning($line, array('source' => 'routeapp')); | |
| 317 | + return; | |
| 318 | + } | |
| 319 | + } | |
| 320 | + if (defined('PHPUNIT_COMPOSER_INSTALL')) { | |
| 321 | + return; | |
| 322 | + } | |
| 323 | + if (function_exists('error_log')) { | |
| 324 | + error_log($line); | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** | |
| 329 | + * All Route webhooks, including disabled duplicates. Woo search_webhooks() | |
| 330 | + * defaults to limit 10, which missed existing hooks and created more. | |
| 331 | + * | |
| 332 | + * @return array | |
| 333 | + */ | |
| 334 | + private function get_existing_route_webhooks() { | |
| 335 | + $grouped = array(); | |
| 336 | + $data_store = \WC_Data_Store::load('webhook'); | |
| 337 | + $ids = $data_store->search_webhooks(array('limit' => -1)); | |
| 338 | + if (!is_array($ids)) { | |
| 339 | + return $grouped; | |
| 340 | + } | |
| 341 | + foreach ($ids as $id) { | |
| 342 | + $webhook = wc_get_webhook($id); | |
| 343 | + if (!self::is_webhook_like($webhook, true)) { | |
| 344 | + continue; | |
| 345 | + } | |
| 346 | + $name = $webhook->get_name(); | |
| 347 | + if (!array_key_exists($name, $this->_webhooksList)) { | |
| 348 | + continue; | |
| 349 | + } | |
| 350 | + $grouped[$name][] = $webhook; | |
| 351 | + } | |
| 352 | + return $grouped; | |
| 353 | + } | |
| 354 | + | |
| 355 | + /** | |
| 356 | + * User that can authorize this webhook's payloads, or 0. | |
| 357 | + * Logs when neither the existing webhook user nor $user_id is valid. | |
| 358 | + * | |
| 359 | + * @param object $webhook Webhook-like object. | |
| 360 | + * @param int $user_id Resolved delivery user. | |
| 361 | + * @return int | |
| 362 | + */ | |
| 363 | + public static function resolve_effective_webhook_user($webhook, $user_id) { | |
| 364 | + $existing_user = (int) $webhook->get_user_id(); | |
| 365 | + if (self::user_can_deliver_webhooks($existing_user)) { | |
| 366 | + return $existing_user; | |
| 367 | + } | |
| 368 | + | |
| 369 | + $user_id = (int) $user_id; | |
| 370 | + if (self::user_can_deliver_webhooks($user_id)) { | |
| 371 | + return $user_id; | |
| 372 | + } | |
| 373 | + | |
| 374 | + $id = method_exists($webhook, 'get_id') ? $webhook->get_id() : 0; | |
| 375 | + $name = method_exists($webhook, 'get_name') ? $webhook->get_name() : ''; | |
| 376 | + self::log_webhook_upsert( | |
| 377 | + 'cannot activate webhook id=' . $id . ' name="' . $name . '"; no WordPress user can authorize order payloads (existing user_id=' . $existing_user . ')' | |
| 378 | + ); | |
| 379 | + return 0; | |
| 380 | + } | |
| 381 | + | |
| 382 | + /** | |
| 383 | + * Whether the merchant paused any copy of this webhook. | |
| 384 | + * | |
| 385 | + * @param array $webhooks Webhook-like objects for one name. | |
| 386 | + * @return bool | |
| 387 | + */ | |
| 388 | + private static function group_has_paused_webhook($webhooks) { | |
| 389 | + foreach ($webhooks as $webhook) { | |
| 390 | + if (self::is_webhook_like($webhook) && 'paused' === $webhook->get_status()) { | |
| 391 | + return true; | |
| 392 | + } | |
| 393 | + } | |
| 394 | + return false; | |
| 395 | + } | |
| 396 | + | |
| 397 | + /** | |
| 398 | + * Merchant opted out of plugin order posting. | |
| 399 | + * | |
| 400 | + * True only when every RouteApp Order Upsert webhook is paused. Missing | |
| 401 | + * webhooks, and Active or Disabled ones, still reconcile: Disabled is | |
| 402 | + * WooCommerce's automatic status after delivery failures. | |
| 403 | + * | |
| 404 | + * @return bool | |
| 405 | + */ | |
| 406 | + public static function is_order_upsert_paused() { | |
| 407 | + if (!class_exists('WC_Data_Store')) { | |
| 408 | + return false; | |
| 409 | + } | |
| 410 | + $data_store = \WC_Data_Store::load('webhook'); | |
| 411 | + $ids = $data_store->search_webhooks(array('limit' => -1)); | |
| 412 | + if (!is_array($ids) || empty($ids)) { | |
| 413 | + return false; | |
| 414 | + } | |
| 415 | + | |
| 416 | + $found = false; | |
| 417 | + foreach ($ids as $id) { | |
| 418 | + $webhook = function_exists('wc_get_webhook') ? wc_get_webhook($id) : false; | |
| 419 | + if (!self::is_webhook_like($webhook, true)) { | |
| 420 | + continue; | |
| 421 | + } | |
| 422 | + if ('RouteApp Order Upsert' !== $webhook->get_name()) { | |
| 423 | + continue; | |
| 424 | + } | |
| 425 | + $found = true; | |
| 426 | + if ('paused' !== $webhook->get_status()) { | |
| 427 | + return false; | |
| 428 | + } | |
| 429 | + } | |
| 430 | + return $found; | |
| 431 | + } | |
| 432 | + | |
| 433 | + /** | |
| 434 | + * Update in place. Do not delete+recreate (that is what duplicated hooks). | |
| 435 | + * | |
| 436 | + * @param object $webhook WC_Webhook. | |
| 437 | + * @param array $values Expected webhook config. | |
| 438 | + * @param string $secret Delivery secret. | |
| 439 | + * @param int $user_id Resolved delivery user. | |
| 440 | + * @return bool True when a valid delivery user is in place. | |
| 441 | + */ | |
| 442 | + private function sync_webhook($webhook, $values, $secret, $user_id) { | |
| 443 | + $changed = false; | |
| 444 | + $existing_user = (int) $webhook->get_user_id(); | |
| 445 | + $effective_user = self::resolve_effective_webhook_user($webhook, $user_id); | |
| 446 | + | |
| 447 | + if ($effective_user && $effective_user !== $existing_user) { | |
| 448 | + $webhook->set_user_id($effective_user); | |
| 449 | + $changed = true; | |
| 450 | + } | |
| 451 | + | |
| 452 | + $current_status = $webhook->get_status(); | |
| 453 | + if ($effective_user && 'paused' !== $current_status && $current_status !== $values['status']) { | |
| 454 | + $webhook->set_status($values['status']); | |
| 455 | + $changed = true; | |
| 456 | + if ('active' === $values['status'] && method_exists($webhook, 'set_failure_count')) { | |
| 457 | + $webhook->set_failure_count(0); | |
| 458 | + } | |
| 459 | + } | |
| 460 | + | |
| 461 | + if ($webhook->get_topic() !== $values['topic']) { | |
| 462 | + $webhook->set_topic($values['topic']); | |
| 463 | + $changed = true; | |
| 464 | + } | |
| 465 | + if ($webhook->get_delivery_url() !== $values['delivery_url']) { | |
| 466 | + $webhook->set_delivery_url($values['delivery_url']); | |
| 467 | + $changed = true; | |
| 468 | + } | |
| 469 | + if ($webhook->get_secret() !== $secret) { | |
| 470 | + $webhook->set_secret($secret); | |
| 471 | + $changed = true; | |
| 472 | + } | |
| 473 | + | |
| 474 | + if ($changed) { | |
| 475 | + $webhook->save(); | |
| 476 | + } | |
| 477 | + | |
| 478 | + return $effective_user > 0; | |
| 479 | + } | |
| 480 | + | |
| 481 | + private function _create_webhook($webhookData, $user_id, $secret) { | |
| 482 | + $user_id = (int) $user_id; | |
| 483 | + if ($user_id < 1) { | |
| 484 | + return; | |
| 485 | + } | |
| 135 | 486 | $webhook = new \WC_Webhook(); |
| 136 | 487 | $webhook->set_name($webhookData['name']); |
| 137 | - $webhook->set_user_id(get_current_user_id()); // User ID used while generating the webhook payload. | |
| 488 | + $webhook->set_user_id($user_id); | |
| 138 | 489 | $webhook->set_topic( $webhookData['topic'] ); // Event used to trigger a webhook. |
| 139 | - $webhook->set_secret( $this->get_secret() ); // Secret to validate webhook when received. | |
| 490 | + $webhook->set_secret( $secret ); // Secret to validate webhook when received. | |
| 140 | 491 | $webhook->set_delivery_url( $webhookData['delivery_url'] ); // URL where webhook should be sent. |
| 141 | 492 | $webhook->set_status( $webhookData['status'] ); // Webhook status. |
| 142 | 493 | $webhook->save(); |
| 143 | 494 | } |
| 144 | 495 | |
| 496 | + /** | |
| 497 | + * Permanently delete a duplicate Route webhook. | |
| 498 | + * | |
| 499 | + * Force-delete is required: we are collapsing failed copies, not moving one | |
| 500 | + * to trash. A soft delete would leave disabled rows that search_webhooks() | |
| 501 | + * would pick up again and recreate the duplication loop. WooCommerce webhook | |
| 502 | + * storage (wc_webhooks table) also has no trash restore path. | |
| 503 | + * | |
| 504 | + * @param int $hookId Webhook ID. | |
| 505 | + * @return void | |
| 506 | + */ | |
| 145 | 507 | private function _remove_webhook($hookId) { |
| 146 | - $wh = new \WC_Webhook(); | |
| 147 | - $wh->set_id($hookId); | |
| 148 | - $wh->delete(); | |
| 508 | + $wh = function_exists('wc_get_webhook') ? wc_get_webhook($hookId) : false; | |
| 509 | + if (!$wh) { | |
| 510 | + $wh = new \WC_Webhook(); | |
| 511 | + $wh->set_id($hookId); | |
| 512 | + } | |
| 513 | + $wh->delete(true); | |
| 149 | 514 | } |
| 150 | 515 | } |