PluginProbe
Sendy / 3.4.7
Sendy v3.4.7
3.4.6 3.4.7 trunk 3.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.2.7 All 32 releases
sendy / lib / Modules / Webhooks.php

Webhooks.php in Sendy 3.4.7, at lib/Modules/Webhooks.php

269 lines 7.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Sendy\WooCommerce\Modules;
4
5 use Sendy\Api\ApiException;
6 use Sendy\Api\Exceptions\SendyException;
7 use Sendy\WooCommerce\ApiClientFactory;
8 use Sendy\WooCommerce\Enums\ProcessingMethod;
9 use WC_Order;
10 use WC_Order_Query;
11
12 class Webhooks
13 {
14 public function __construct()
15 {
16 add_action('update_option_sendy_processing_method', [$this, 'handle_sendy_processing_method_change'], 10, 3);
17 add_action('rest_api_init', [$this, 'init_rest_api_endpoint']);
18 add_action('sendy_cron', [$this, 'ensure_webhook_installed']);
19 }
20
21 /**
22 * Create or delete the webhook based on the new value
23 *
24 * @param mixed $oldValue
25 * @param mixed $newValue
26 */
27 public function handle_sendy_processing_method_change($oldValue, $newValue): void
28 {
29 if ($oldValue === $newValue || ! in_array($newValue, ProcessingMethod::cases())) {
30 return;
31 }
32
33 if ($newValue === ProcessingMethod::WooCommerce) {
34 $this->deleteWebhook();
35 }
36
37 if ($newValue === ProcessingMethod::Sendy) {
38 $this->createWebhook();
39 }
40 }
41
42 public function init_rest_api_endpoint(): void
43 {
44 if (get_option('sendy_processing_method') != ProcessingMethod::Sendy) {
45 return;
46 }
47
48 register_rest_route('sendy/v1', '/webhook', [
49 'methods' => 'POST',
50 'callback' => [$this, 'webhook_callback'],
51 'permission_callback' => function () { return true; },
52 ]);
53 }
54
55 public function webhook_callback(\WP_REST_Request $request)
56 {
57 $verificationError = $this->verifySignature($request);
58
59 if ($verificationError) {
60 return $verificationError;
61 }
62
63 $payload = $request->get_json_params() ?? [];
64
65 if (! array_key_exists('data', $payload)) {
66 return;
67 }
68
69 switch ($payload['data']['event']) {
70 case 'shipment.generated':
71 $this->handleShipmentGenerated($payload['data']['id']);
72 break;
73
74 case 'shipment.cancelled':
75 case 'shipment.deleted':
76 $this->handleShipmentDeletedOrCancelled($payload['data']['id']);
77 break;
78
79 case 'shipment.delivered':
80 $this->handleShipmentDelivered($payload['data']['id']);
81 break;
82 }
83
84 return rest_ensure_response([
85 'status' => 'success',
86 'message' => 'Webhook processed',
87 ]);
88 }
89
90 public function ensure_webhook_installed(): void
91 {
92 if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) {
93 return;
94 }
95
96 if (get_option('sendy_webhook_last_checked') >= time() - 24 * 60 * 60) {
97 return;
98 }
99
100 try {
101 $webhooks = ApiClientFactory::buildConnectionUsingTokens()->webhook->list();
102
103 $webhookIds = array_map(function ($webhook) {
104 return $webhook['id'];
105 }, $webhooks);
106
107 if (! get_option('sendy_webhook_id') || ! in_array(get_option('sendy_webhook_id'), $webhookIds)) {
108 $this->createWebhook();
109 }
110
111 update_option('sendy_webhook_last_checked', time());
112 } catch (ApiException $exception) {
113 return;
114 }
115 }
116
117 /**
118 * @throws SendyException
119 */
120 public static function regenerateWebhookSecret(): void
121 {
122 $clientId = get_option('sendy_client_id');
123 $response = ApiClientFactory::buildConnectionUsingTokens()
124 ->post("/regenerate-webhook-secret/{$clientId}");
125
126 update_option('sendy_webhook_secret', $response['webhook_secret'], false);
127 }
128
129 public function deactivate(): void
130 {
131 $this->deleteWebhook();
132
133 delete_option('sendy_webhook_last_checked');
134 }
135
136 private function verifySignature(\WP_REST_Request $request): ?\WP_REST_Response
137 {
138 $signature = $request->get_header('X-Signature');
139 $timestamp = $request->get_header('X-Timestamp');
140
141 if (! $signature || ! $timestamp) {
142 return new \WP_REST_Response(['error' => 'Missing signature headers'], 401);
143 }
144
145 $secret = get_option('sendy_webhook_secret');
146
147 if (! $secret) {
148 try {
149 self::regenerateWebhookSecret();
150 } catch (\Exception $e) {
151 return new \WP_REST_Response(['error' => 'Failed to regenerate webhook secret'], 500);
152 }
153
154 return new \WP_REST_Response(['error' => 'Webhook secret not configured'], 401);
155 }
156
157 $expected = hash_hmac('sha256', $timestamp . $request->get_body(), $secret);
158
159 if (! hash_equals($expected, $signature)) {
160 return new \WP_REST_Response(['error' => 'Invalid signature'], 401);
161 }
162
163 return null;
164 }
165
166 /**
167 * Delete the webhook in the API
168 */
169 private function deleteWebhook(): void
170 {
171 $webhookId = get_option('sendy_webhook_id');
172
173 if ($webhookId) {
174 try {
175 ApiClientFactory::buildConnectionUsingTokens()->webhook->delete($webhookId);
176 } catch (ApiException $exception) {
177 // Webhook was likely already deleted
178 } finally {
179 delete_option('sendy_webhook_id');
180 }
181 }
182 }
183
184 /**
185 * Create the webhook in the API
186 */
187 private function createWebhook(): void
188 {
189 try {
190 $webhook = ApiClientFactory::buildConnectionUsingTokens()->webhook->create([
191 'url' => get_rest_url(null, 'sendy/v1/webhook', 'https'),
192 'events' => [
193 'shipment.generated',
194 'shipment.deleted',
195 'shipment.cancelled',
196 'shipment.delivered',
197 ],
198 ]);
199
200 update_option('sendy_webhook_id', $webhook['id']);
201 } catch (ApiException $exception) {
202
203 }
204 }
205
206 private function handleShipmentGenerated(string $shipmentId): void
207 {
208 $order = $this->fetchOrderByShipmentId($shipmentId);
209
210 if ($order) {
211 $shipment = ApiClientFactory::buildConnectionUsingTokens()->shipment->get($shipmentId);
212
213 $order->update_meta_data('_sendy_packages', $shipment['packages']);
214
215 if (get_option('sendy_mark_order_as_completed') === 'after-shipment-created') {
216 $order->set_status('completed', __('Sendy: Shipment created', 'sendy'));
217 }
218
219 $order->save();
220 }
221 }
222
223 private function handleShipmentDeletedOrCancelled(string $shipmentId): void
224 {
225 $order = $this->fetchOrderByShipmentId($shipmentId);
226
227 if ($order) {
228 $order->update_meta_data('_sendy_packages', null);
229 $order->update_meta_data('_sendy_shipment_id', null);
230 $order->save();
231 }
232 }
233
234 private function handleShipmentDelivered(string $shipmentId): void
235 {
236 if (get_option('sendy_mark_order_as_completed') !== 'after-shipment-delivered') {
237 return;
238 }
239
240 $order = $this->fetchOrderByShipmentId($shipmentId);
241
242 if ($order) {
243 $order->set_status('completed', __('Sendy: Shipment delivered', 'sendy'));
244 $order->save();
245 }
246 }
247
248 private function fetchOrderByShipmentId(string $shipmentId): ?WC_Order
249 {
250 $query = new WC_Order_Query([
251 'limit' => 1,
252 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
253 'meta_key' => '_sendy_shipment_id',
254 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
255 'meta_value' => $shipmentId,
256 'meta_compare' => '=',
257 ]);
258
259 /** @var list<WC_Order> $result */
260 $result = $query->get_orders();
261
262 if (empty($result)) {
263 return null;
264 }
265
266 return $result[0];
267 }
268 }
269