PluginProbe
Route ‑ Shipping Protection / 2.4.18
Route ‑ Shipping Protection v2.4.18
2.4.19 2.4.18 2.4.17 2.4.16 2.4.15 2.4.14 2.4.13 2.4.12 2.4.11 2.4.10 2.4.9 2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.2.4 2.2.5 2.2.6 2.2.8 2.2.9 2.3.0 2.3.1 All 153 releases
← All changes | admin/class-routeapp-webhooks.php +366 -37 2.4.11 → 2.4.18 View file →
@@ -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,396 @@
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 + * Update in place. Do not delete+recreate (that is what duplicated hooks).
399 + *
400 + * @param object $webhook WC_Webhook.
401 + * @param array $values Expected webhook config.
402 + * @param string $secret Delivery secret.
403 + * @param int $user_id Resolved delivery user.
404 + * @return bool True when a valid delivery user is in place.
405 + */
406 + private function sync_webhook($webhook, $values, $secret, $user_id) {
407 + $changed = false;
408 + $existing_user = (int) $webhook->get_user_id();
409 + $effective_user = self::resolve_effective_webhook_user($webhook, $user_id);
410 +
411 + if ($effective_user && $effective_user !== $existing_user) {
412 + $webhook->set_user_id($effective_user);
413 + $changed = true;
414 + }
415 +
416 + $current_status = $webhook->get_status();
417 + if ($effective_user && 'paused' !== $current_status && $current_status !== $values['status']) {
418 + $webhook->set_status($values['status']);
419 + $changed = true;
420 + if ('active' === $values['status'] && method_exists($webhook, 'set_failure_count')) {
421 + $webhook->set_failure_count(0);
422 + }
423 + }
424 +
425 + if ($webhook->get_topic() !== $values['topic']) {
426 + $webhook->set_topic($values['topic']);
427 + $changed = true;
428 + }
429 + if ($webhook->get_delivery_url() !== $values['delivery_url']) {
430 + $webhook->set_delivery_url($values['delivery_url']);
431 + $changed = true;
432 + }
433 + if ($webhook->get_secret() !== $secret) {
434 + $webhook->set_secret($secret);
435 + $changed = true;
436 + }
437 +
438 + if ($changed) {
439 + $webhook->save();
440 + }
441 +
442 + return $effective_user > 0;
443 + }
444 +
445 + private function _create_webhook($webhookData, $user_id, $secret) {
446 + $user_id = (int) $user_id;
447 + if ($user_id < 1) {
448 + return;
449 + }
135 450 $webhook = new \WC_Webhook();
136 451 $webhook->set_name($webhookData['name']);
137 - $webhook->set_user_id(get_current_user_id()); // User ID used while generating the webhook payload.
452 + $webhook->set_user_id($user_id);
138 453 $webhook->set_topic( $webhookData['topic'] ); // Event used to trigger a webhook.
139 - $webhook->set_secret( $this->get_secret() ); // Secret to validate webhook when received.
454 + $webhook->set_secret( $secret ); // Secret to validate webhook when received.
140 455 $webhook->set_delivery_url( $webhookData['delivery_url'] ); // URL where webhook should be sent.
141 456 $webhook->set_status( $webhookData['status'] ); // Webhook status.
142 457 $webhook->save();
143 458 }
144 459
460 + /**
461 + * Permanently delete a duplicate Route webhook.
462 + *
463 + * Force-delete is required: we are collapsing failed copies, not moving one
464 + * to trash. A soft delete would leave disabled rows that search_webhooks()
465 + * would pick up again and recreate the duplication loop. WooCommerce webhook
466 + * storage (wc_webhooks table) also has no trash restore path.
467 + *
468 + * @param int $hookId Webhook ID.
469 + * @return void
470 + */
145 471 private function _remove_webhook($hookId) {
146 - $wh = new \WC_Webhook();
147 - $wh->set_id($hookId);
148 - $wh->delete();
472 + $wh = function_exists('wc_get_webhook') ? wc_get_webhook($hookId) : false;
473 + if (!$wh) {
474 + $wh = new \WC_Webhook();
475 + $wh->set_id($hookId);
476 + }
477 + $wh->delete(true);
149 478 }
150 479 }