PluginProbe
Route ‑ Shipping Protection / 2.4.14
Route ‑ Shipping Protection v2.4.14
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.14, at admin/class-routeapp-webhooks.php

457 lines 15.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 if (!$this->sync_webhook($keeper, $values, $secret, $user_id)) {
271 $missing[] = $name;
272 }
273 }
274
275 self::finalize_webhooks_created_flag($missing);
276 }
277 }
278
279 /**
280 * Mark webhooks created only when every required hook exists.
281 * Leaving the flag unset lets admin_init retry on the next logged-in request.
282 *
283 * @param array $missing_names Names that could not be created (no delivery user).
284 * @return bool True if the created flag was set.
285 */
286 public static function finalize_webhooks_created_flag($missing_names) {
287 if (!empty($missing_names)) {
288 self::log_webhook_upsert(
289 'did not mark webhooks created; no WordPress user can authorize order payloads. Missing: ' . implode(', ', $missing_names)
290 );
291 if (function_exists('delete_option')) {
292 delete_option('_routeapp_webhooks_created');
293 }
294 return false;
295 }
296 update_option('_routeapp_webhooks_created', 1);
297 return true;
298 }
299
300 /**
301 * @param string $message Log line.
302 * @return void
303 */
304 public static function log_webhook_upsert($message) {
305 $line = '[routeapp] ' . $message;
306 if (function_exists('wc_get_logger')) {
307 $logger = wc_get_logger();
308 if (is_object($logger) && method_exists($logger, 'warning')) {
309 $logger->warning($line, array('source' => 'routeapp'));
310 return;
311 }
312 }
313 if (defined('PHPUNIT_COMPOSER_INSTALL')) {
314 return;
315 }
316 if (function_exists('error_log')) {
317 error_log($line);
318 }
319 }
320
321 /**
322 * All Route webhooks, including disabled duplicates. Woo search_webhooks()
323 * defaults to limit 10, which missed existing hooks and created more.
324 *
325 * @return array
326 */
327 private function get_existing_route_webhooks() {
328 $grouped = array();
329 $data_store = \WC_Data_Store::load('webhook');
330 $ids = $data_store->search_webhooks(array('limit' => -1));
331 if (!is_array($ids)) {
332 return $grouped;
333 }
334 foreach ($ids as $id) {
335 $webhook = wc_get_webhook($id);
336 if (!self::is_webhook_like($webhook, true)) {
337 continue;
338 }
339 $name = $webhook->get_name();
340 if (!array_key_exists($name, $this->_webhooksList)) {
341 continue;
342 }
343 $grouped[$name][] = $webhook;
344 }
345 return $grouped;
346 }
347
348 /**
349 * User that can authorize this webhook's payloads, or 0.
350 * Logs when neither the existing webhook user nor $user_id is valid.
351 *
352 * @param object $webhook Webhook-like object.
353 * @param int $user_id Resolved delivery user.
354 * @return int
355 */
356 public static function resolve_effective_webhook_user($webhook, $user_id) {
357 $existing_user = (int) $webhook->get_user_id();
358 if (self::user_can_deliver_webhooks($existing_user)) {
359 return $existing_user;
360 }
361
362 $user_id = (int) $user_id;
363 if (self::user_can_deliver_webhooks($user_id)) {
364 return $user_id;
365 }
366
367 $id = method_exists($webhook, 'get_id') ? $webhook->get_id() : 0;
368 $name = method_exists($webhook, 'get_name') ? $webhook->get_name() : '';
369 self::log_webhook_upsert(
370 'cannot activate webhook id=' . $id . ' name="' . $name . '"; no WordPress user can authorize order payloads (existing user_id=' . $existing_user . ')'
371 );
372 return 0;
373 }
374
375 /**
376 * Update in place. Do not delete+recreate (that is what duplicated hooks).
377 *
378 * @param object $webhook WC_Webhook.
379 * @param array $values Expected webhook config.
380 * @param string $secret Delivery secret.
381 * @param int $user_id Resolved delivery user.
382 * @return bool True when a valid delivery user is in place.
383 */
384 private function sync_webhook($webhook, $values, $secret, $user_id) {
385 $changed = false;
386 $existing_user = (int) $webhook->get_user_id();
387 $effective_user = self::resolve_effective_webhook_user($webhook, $user_id);
388
389 if ($effective_user && $effective_user !== $existing_user) {
390 $webhook->set_user_id($effective_user);
391 $changed = true;
392 }
393
394 if ($effective_user && $webhook->get_status() !== $values['status']) {
395 $webhook->set_status($values['status']);
396 $changed = true;
397 if (method_exists($webhook, 'set_failure_count')) {
398 $webhook->set_failure_count(0);
399 }
400 }
401
402 if ($webhook->get_topic() !== $values['topic']) {
403 $webhook->set_topic($values['topic']);
404 $changed = true;
405 }
406 if ($webhook->get_delivery_url() !== $values['delivery_url']) {
407 $webhook->set_delivery_url($values['delivery_url']);
408 $changed = true;
409 }
410 if ($webhook->get_secret() !== $secret) {
411 $webhook->set_secret($secret);
412 $changed = true;
413 }
414
415 if ($changed) {
416 $webhook->save();
417 }
418
419 return $effective_user > 0;
420 }
421
422 private function _create_webhook($webhookData, $user_id, $secret) {
423 $user_id = (int) $user_id;
424 if ($user_id < 1) {
425 return;
426 }
427 $webhook = new \WC_Webhook();
428 $webhook->set_name($webhookData['name']);
429 $webhook->set_user_id($user_id);
430 $webhook->set_topic( $webhookData['topic'] ); // Event used to trigger a webhook.
431 $webhook->set_secret( $secret ); // Secret to validate webhook when received.
432 $webhook->set_delivery_url( $webhookData['delivery_url'] ); // URL where webhook should be sent.
433 $webhook->set_status( $webhookData['status'] ); // Webhook status.
434 $webhook->save();
435 }
436
437 /**
438 * Permanently delete a duplicate Route webhook.
439 *
440 * Force-delete is required: we are collapsing failed copies, not moving one
441 * to trash. A soft delete would leave disabled rows that search_webhooks()
442 * would pick up again and recreate the duplication loop. WooCommerce webhook
443 * storage (wc_webhooks table) also has no trash restore path.
444 *
445 * @param int $hookId Webhook ID.
446 * @return void
447 */
448 private function _remove_webhook($hookId) {
449 $wh = function_exists('wc_get_webhook') ? wc_get_webhook($hookId) : false;
450 if (!$wh) {
451 $wh = new \WC_Webhook();
452 $wh->set_id($hookId);
453 }
454 $wh->delete(true);
455 }
456 }
457