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
routeapp / admin / class-routeapp-webhooks.php

class-routeapp-webhooks.php in Route ‑ Shipping Protection 2.4.18, at admin/class-routeapp-webhooks.php

480 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Handle webhook upserts.
5 *
6 *
7 * @package Routeapp
8 * @subpackage Routeapp/admin
9 * @author Route App <[email protected]>
10 */
11 class Routeapp_Webhooks {
12
13 const API_GATEWAY_ENDPOINT = 'https://distributed-api.route.com';
14 const API_GATEWAY_ENDPOINT_STAGING = 'https://distributed-api-stage.route.com';
15 const WEBHOOK_USER_OPTION = '_routeapp_webhook_user_id';
16
17 const webhooksList = [
18 // order.updated will trigger both order update and order create events
19 'RouteApp Order Upsert' => [
20 'name' => 'RouteApp Order Upsert',
21 'status' => 'active',
22 'topic' => 'order.updated',
23 'delivery_url' => '/webhooks/',
24 'update' => false,
25 ],
26 'RouteApp Order Deleted' => [
27 'name' => 'RouteApp Order Deleted',
28 'status' => 'active',
29 'topic' => 'order.deleted',
30 'delivery_url' => '/webhooks/',
31 'update' => false,
32 ],
33 ];
34 private $_webhooksList;
35
36 public function __construct() {
37 $this->buildWebhookList();
38 }
39
40 private function buildWebhookList()
41 {
42 $webhooksList = self::webhooksList;
43 foreach ($webhooksList as $webhookName => $values) {
44 $webhooksList[$webhookName]['delivery_url'] = $this->generate_delivery_url($values['delivery_url']);
45 }
46 $this->_webhooksList = $webhooksList;
47 }
48
49 private function generate_delivery_url($url) {
50 $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT');
51 if (is_null($custom_env) || !$custom_env) {
52 $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
53 }
54
55 $delivery_url = $custom_env == 'stage' ? self::API_GATEWAY_ENDPOINT_STAGING : self::API_GATEWAY_ENDPOINT;
56 $delivery_url.= $url . get_option('routeapp_merchant_id');
57 return $delivery_url;
58 }
59
60 private function generate_default_secret() {
61 $merchant_id = get_option('routeapp_merchant_id');
62 $secret_token = get_option('routeapp_secret_token');
63 $string = $merchant_id . '-' . $secret_token;
64 return hash('sha256', $string);
65 }
66
67 public function set_secret() {
68 $secret = $this->generate_default_secret();
69 update_option( '_routeapp_webhooks_secret', $secret);
70 return $secret;
71 }
72
73 public function get_secret() {
74 $secret = get_option('_routeapp_webhooks_secret', false);
75 if (!$secret) {
76 $secret = $this->set_secret();
77 }
78 return $secret;
79 }
80
81 private function _is_routedata_complete()
82 {
83 $merchant_id = get_option('routeapp_merchant_id', false);
84 $secret_token = get_option('routeapp_secret_token', false);
85 return $merchant_id && $secret_token;
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
232 public function upsert_webhooks() {
233 //rebuild webhooks list to avoid issues with merchant_id on construct
234 $this->buildWebhookList();
235
236 if ( class_exists( 'WC_Data_Store' ) && $this->_is_routedata_complete() ) {
237 $secret = $this->get_secret();
238 $user_id = self::get_webhook_user_id();
239 $grouped = $this->get_existing_route_webhooks();
240
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;
249 }
250 continue;
251 }
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;
259 }
260 continue;
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 }
280 }
281
282 self::finalize_webhooks_created_flag($missing);
283 }
284 }
285
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 }
450 $webhook = new \WC_Webhook();
451 $webhook->set_name($webhookData['name']);
452 $webhook->set_user_id($user_id);
453 $webhook->set_topic( $webhookData['topic'] ); // Event used to trigger a webhook.
454 $webhook->set_secret( $secret ); // Secret to validate webhook when received.
455 $webhook->set_delivery_url( $webhookData['delivery_url'] ); // URL where webhook should be sent.
456 $webhook->set_status( $webhookData['status'] ); // Webhook status.
457 $webhook->save();
458 }
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 */
471 private function _remove_webhook($hookId) {
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);
478 }
479 }
480