PluginProbe
Metricool – Social media and site statistics / 2.0.2
Metricool – Social media and site statistics v2.0.2
2.1.0 2.0.2 2.0.1 2.0.0 1.27 trunk
metricool / app / Http / Metricool / MetricoolClient.php

MetricoolClient.php in Metricool – Social media and site statistics 2.0.2, at app/Http/Metricool/MetricoolClient.php

698 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Metricool\Http\Metricool;
6
7 use Carbon\Carbon;
8 use RuntimeException;
9 use GuzzleHttp\Client;
10 use GuzzleHttp\HandlerStack;
11 use GuzzleHttp\Psr7\Request;
12 use InvalidArgumentException;
13 use Metricool\Services\OptionsService;
14 use Psr\Http\Message\ResponseInterface;
15 use GuzzleHttp\Exception\GuzzleException;
16 use Metricool\Http\Metricool\Exceptions\ApiException;
17 use Metricool\Support\Helpers\Storages\EnvironmentConfig;
18 use Throwable;
19
20 class MetricoolClient
21 {
22 private const OPTION_USER_ID = 'metricool_user_id';
23 private const OPTION_BLOG_ID = 'metricool_blog_id';
24 private const OPTION_AUTH_TOKEN = 'metricool_auth_token';
25 private const OPTION_REFRESH_TOKEN = 'metricool_refresh_token';
26 private const OPTION_AUTH_TOKEN_EXPIRES = 'metricool_auth_token_expires';
27 private const OPTION_REFRESH_LOCK = 'metricool_refresh_lock';
28
29 private const LOCK_STALE_MS = 15000;
30 private const LOCK_WAIT_SLEEP_MS = 100;
31 private const LOCK_WAIT_MAX_MS = 5000;
32
33 private ?Client $client = null;
34
35 private EnvironmentConfig $env;
36 private OptionsService $options;
37
38 private string $apiUrl;
39 private string $userToken = '';
40 private string $blogId = '';
41 private string $userId = '';
42 protected array $middleWares = [];
43
44
45 /**
46 * Create a new Metricool API client wrapper.
47 */
48 public function __construct(EnvironmentConfig $env, OptionsService $options)
49 {
50 $this->env = $env;
51 $this->options = $options;
52 $this->apiUrl = $env->get('metricool.base_api_domain');
53 }
54
55 /**
56 * Set the authenticated Metricool user ID.
57 */
58 public function setUserId(string $userId): void
59 {
60 $this->userId = $userId;
61 }
62
63 /**
64 * Get the authenticated Metricool user ID.
65 */
66 public function getUserId(): string
67 {
68 return $this->userId;
69 }
70
71 /**
72 * Check whether a Metricool user ID is available.
73 */
74 public function hasUserId(): bool
75 {
76 return !empty($this->userId);
77 }
78
79 /**
80 * Persist and set the Metricool user ID.
81 */
82 public function storeUserId(string $userId): void
83 {
84 update_option(self::OPTION_USER_ID, $userId);
85
86 $this->setUserId($userId);
87 }
88
89 /**
90 * Clear the persisted Metricool user ID.
91 */
92 public function clearUserId(): void
93 {
94 delete_option(self::OPTION_USER_ID);
95
96 $this->setUserId('');
97 }
98
99 /**
100 * Get the selected Metricool blog ID.
101 */
102 public function getBlogId(): string
103 {
104 return $this->blogId;
105 }
106
107 /**
108 * Set the selected Metricool blog ID.
109 */
110 public function setBlogId(string $blogId): void
111 {
112 $this->blogId = $blogId;
113 }
114
115 /**
116 * Persist and set the Metricool blog ID.
117 */
118 public function storeBlogId(string $blogId): void
119 {
120 update_option(self::OPTION_BLOG_ID, $blogId);
121
122 $this->setBlogId($blogId);
123 }
124
125 /**
126 * Clear the persisted Metricool blog ID.
127 */
128 public function clearBlogId(): void
129 {
130 delete_option(self::OPTION_BLOG_ID);
131
132 $this->setBlogId('');
133 }
134
135 /**
136 * Check whether a Metricool blog ID is available.
137 */
138 public function hasBlogId(): bool
139 {
140 return !empty($this->blogId);
141 }
142
143 /**
144 * Get the current access token.
145 */
146 public function getUserToken(): string
147 {
148 return $this->userToken;
149 }
150
151 /**
152 * Set the current access token.
153 */
154 public function setUserToken(string $userToken): void
155 {
156 $this->userToken = $userToken;
157 }
158
159 /**
160 * Check whether an access token is available.
161 */
162 public function hasUserToken(): bool
163 {
164 return !empty($this->userToken);
165 }
166
167 /**
168 * Persist and set the current access token.
169 */
170 public function storeUserToken(string $token): void
171 {
172 update_option(self::OPTION_AUTH_TOKEN, $token);
173
174 $this->setUserToken($token);
175 }
176
177 /**
178 * Clear the persisted access token.
179 */
180 public function clearUserToken(): void
181 {
182 delete_option(self::OPTION_AUTH_TOKEN);
183
184 $this->setUserToken('');
185 }
186
187 /**
188 * Get the persisted refresh token.
189 */
190 public function getRefreshToken(): string
191 {
192 return get_option(self::OPTION_REFRESH_TOKEN);
193 }
194
195 /**
196 * Persist the refresh token.
197 */
198 public function storeRefreshToken(string $refreshToken): void
199 {
200 update_option(self::OPTION_REFRESH_TOKEN, $refreshToken);
201 }
202
203 /**
204 * Clear the persisted refresh token data.
205 */
206 public function clearRefreshToken(): void
207 {
208 delete_option(self::OPTION_REFRESH_TOKEN);
209 delete_option(self::OPTION_AUTH_TOKEN_EXPIRES);
210 }
211
212 /**
213 * Get the token expiration timestamp with a raw query to avoid retrieving
214 * the option from the WordPress object cache.
215 * @internal Not using get_option() is on purpose!
216 */
217 public function getTokenExpires(): int
218 {
219 global $wpdb;
220
221 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
222 return (int) $wpdb->get_var(
223 $wpdb->prepare(
224 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
225 self::OPTION_AUTH_TOKEN_EXPIRES
226 )
227 );
228 }
229
230 /**
231 * Get the token expiration as a Carbon date.
232 */
233 public function tokenExpiresAt(): Carbon
234 {
235 return Carbon::createFromTimestamp($this->getTokenExpires());
236 }
237
238 /**
239 * Determine whether the access token is expired. This uses a 1-minute buffer,
240 * to account for clock skew and request time.
241 */
242 public function isTokenExpired(): bool
243 {
244 return Carbon::now()->gt($this->tokenExpiresAt()->subMinute());
245 }
246
247 /**
248 * Persist the token expiration time.
249 */
250 public function storeTokenExpires(int $expiresIn): void
251 {
252 $expiresIn = Carbon::now()->addSeconds($expiresIn)->timestamp;
253
254 update_option(self::OPTION_AUTH_TOKEN_EXPIRES, $expiresIn);
255 }
256
257 /**
258 * Register a middleware for outgoing requests.
259 */
260 public function insertMiddleWare(callable $middleWare): void
261 {
262 $this->middleWares[] = $middleWare;
263 }
264
265 /**
266 * Connect and return the configured HTTP client.
267 */
268 public function connect(): Client
269 {
270 return $this->client();
271 }
272
273 /**
274 * Check whether the HTTP client has been initialized.
275 */
276 public function isConnected(): bool
277 {
278 return ($this->client instanceof Client);
279 }
280
281 /**
282 * Set the authentication tokens and userId.
283 */
284 public function authenticate(string $userId, string $userToken, string $refreshToken, int $expires): self
285 {
286 $this->storeUserId($userId);
287 $this->storeUserToken($userToken);
288 $this->storeRefreshToken($refreshToken);
289 $this->storeTokenExpires($expires);
290
291 return $this;
292 }
293
294 /**
295 * Clear the authentication tokens and userId.
296 */
297 public function logout(): void
298 {
299 $this->options->wipe();
300 }
301
302 /**
303 * Check if the client has all the necessary authentication tokens to show the dashboard
304 */
305 public function hasAuthentication(): bool
306 {
307 return $this->hasUserToken() && $this->hasUserId();
308 }
309
310 /**
311 * Build the middleware stack for the HTTP client.
312 */
313 protected function middleware(): HandlerStack
314 {
315 $handlerStack = HandlerStack::create();
316 foreach ($this->middleWares as $middleWare) {
317 $handlerStack->push($middleWare);
318 }
319 return $handlerStack;
320 }
321
322 /**
323 * Build or return the configured HTTP client instance.
324 */
325 private function client(): Client
326 {
327 if ($this->client) {
328 return $this->client;
329 }
330
331 $this->client = new Client([
332 'http_errors' => true,
333 'handler' => $this->middleware(),
334 'expect' => false,
335 'headers' => [
336 'Accept' => 'application/json',
337 'Content-Type' => 'application/json',
338 'User-Agent' => $this->getRequestUserAgent(),
339 ]
340 ]);
341
342 return $this->client;
343 }
344
345 /**
346 * Get the user agent string for the request.
347 */
348 public function getRequestUserAgent(): string
349 {
350 return "MetricoolPlugin/" . $this->env->getString('plugin.version') . " (WordPress/" . get_bloginfo('version') . "; PHP/" . phpversion() . "; ref: " . $this->getReferrer() . "; +" . site_url() . ")";
351 }
352
353 /**
354 * EXTENDIFY_PARTNER_ID will contain the required value if WordPress is
355 * configured using Extendify. Otherwise, use default 'wp'.
356 */
357 public function getReferrer(): string
358 {
359 return (defined('EXTENDIFY_PARTNER_ID') ? constant('EXTENDIFY_PARTNER_ID') : 'wp');
360 }
361
362 /**
363 * Send a GET request.
364 * @throws ApiException
365 */
366 public function get(string $endpoint): ?array
367 {
368 return $this->request('GET', $endpoint);
369 }
370
371 /**
372 * Send a POST request.
373 * @throws ApiException
374 */
375 public function post(string $endpoint, array $body): ?array
376 {
377 return $this->request('POST', $endpoint, $body);
378 }
379
380 /**
381 * Send a PUT request.
382 * @throws ApiException
383 */
384 public function put(string $endpoint, array $body): ?array
385 {
386 return $this->request('PUT', $endpoint, $body);
387 }
388
389 /**
390 * Send a PATCH request.
391 * @throws ApiException
392 */
393 public function patch(string $endpoint, array $body): ?array
394 {
395 return $this->request('PATCH', $endpoint, $body);
396 }
397
398 /**
399 * Send a DELETE request.
400 * @throws ApiException
401 */
402 public function delete(string $endpoint): ?array
403 {
404 return $this->request('DELETE', $endpoint);
405 }
406
407 /**
408 * Send an authenticated request to the Metricool API.
409 *
410 * @param mixed|null $body
411 * @throws ApiException
412 */
413 public function request(string $method, string $endpoint, $body = null): ?array
414 {
415 $this->validate();
416
417 if ($this->isTokenExpired()) {
418 $this->refreshAuthToken();
419 }
420
421 try {
422 $response = $this->client->send(
423 new Request($method, $this->formatUrl($endpoint), [
424 'Authorization' => 'Bearer ' . $this->userToken
425 ], json_encode($body))
426 );
427 } catch (Throwable $e) {
428 if ($e instanceof GuzzleException && $e->getCode() === 401) {
429 $this->logout();
430 }
431
432 throw new ApiException(
433 $e->getMessage(),
434 $e->getCode(),
435 $e
436 );
437 }
438
439 return $this->parseResponse($response);
440 }
441
442 /**
443 * Exchange an OAuth authorization code for an access token.
444 * @throws ApiException
445 */
446 public function exchangeOAuthCode(string $code, string $redirectUri): array
447 {
448 $headers = [
449 'Accept' => 'application/json',
450 'Content-Type' => 'application/x-www-form-urlencoded',
451 ];
452
453 $options = [
454 'form_params' => [
455 'grant_type' => 'authorization_code',
456 'client_id' => $this->env->getString('metricool.oauth_client_id'),
457 'code' => $code,
458 'redirect_uri' => $redirectUri,
459 'code_verifier' => 'login',
460 ],
461 ];
462
463 try {
464 $response = $this->client->send(
465 new Request('POST', $this->env->getString('metricool.oauth_token_url'), $headers),
466 $options
467 );
468 } catch (Throwable $e) {
469 throw new ApiException(
470 $e->getMessage(),
471 $e->getCode(),
472 $e
473 );
474 }
475
476 return $this->parseResponse($response);
477 }
478
479 /**
480 * Refresh the authentication token using the refresh token.
481 *
482 * Uses a MySQL lock to prevent concurrent processes from both
483 * attempting a refresh. The process that cannot acquire the lock
484 * waits in a loop until the token is refreshed by the lock holder.
485 *
486 * @throws ApiException when the refresh request fails or the
487 * response is invalid.
488 * @throws RuntimeException when polling times out.
489 */
490 public function refreshAuthToken(): void
491 {
492 $lockAcquired = $this->lockTokenRefresh();
493 if ($lockAcquired === false) {
494 $this->pollForNewUserToken();
495 return;
496 }
497
498 try {
499 $this->performTokenRefresh();
500 } finally {
501 $this->releaseRefreshLock();
502 }
503 }
504
505 /**
506 * Acquire a lock via wp_options to serialize token refresh attempts.
507 * Uses INSERT IGNORE for atomicity: only one process can create the row.
508 */
509 private function lockTokenRefresh(): bool
510 {
511 global $wpdb;
512
513 // Remove stale locks that might be left behind if a process crashes during refresh. We consider locks older than LOCK_STALE_MS as stale.
514 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
515 $wpdb->query(
516 $wpdb->prepare(
517 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value < %d",
518 self::OPTION_REFRESH_LOCK,
519 time() - (int) (self::LOCK_STALE_MS / 1000)
520 )
521 );
522
523 // Attempt to insert the lock row. INSERT IGNORE ensures only one process succeeds when racing concurrently.
524 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
525 $result = $wpdb->query(
526 $wpdb->prepare(
527 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %d, 'no')",
528 self::OPTION_REFRESH_LOCK,
529 time()
530 )
531 );
532
533 return ($result !== false && $result > 0);
534 }
535
536 /**
537 * Wait for another process to refresh the token by polling if the token is expired
538 * @throws RuntimeException if the token is still expired after waiting for the maximum time.
539 */
540 private function pollForNewUserToken(): void
541 {
542 $maxWait = self::LOCK_WAIT_MAX_MS;
543 $sleepDuration = self::LOCK_WAIT_SLEEP_MS;
544 $waited = 0;
545
546 while ($waited < $maxWait) {
547 if ($this->isTokenExpired() === false) {
548 $this->setUserToken($this->fetchUserToken());
549 return;
550 }
551
552 usleep($sleepDuration * 1000);
553 $waited += $sleepDuration;
554 }
555
556 throw new \RuntimeException('Timed out waiting for token refresh. Please try again.');
557 }
558
559 /**
560 * Read the access token directly from the database, bypassing the
561 * WordPress object cache.
562 */
563 private function fetchUserToken(): string
564 {
565 global $wpdb;
566
567 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
568 return (string) $wpdb->get_var(
569 $wpdb->prepare(
570 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
571 self::OPTION_AUTH_TOKEN
572 )
573 );
574 }
575
576 /**
577 * Perform the actual token refresh request against the
578 * Metricool OAuth endpoint.
579 *
580 * @throws ApiException when the refresh request fails or the
581 * response is invalid.
582 */
583 private function performTokenRefresh(): void
584 {
585 $headers = [
586 'Accept' => 'application/json',
587 'Content-Type' => 'application/x-www-form-urlencoded',
588 ];
589
590 $options = [
591 'form_params' => [
592 'client_id' => $this->env->getString('metricool.oauth_client_id'),
593 'grant_type' => 'refresh_token',
594 'refresh_token' => $this->getRefreshToken(),
595 ]
596 ];
597
598 try {
599 $response = $this->client->send(
600 new Request('POST', $this->env->getString('metricool.oauth_token_url'), $headers),
601 $options
602 );
603 } catch (Throwable $e) {
604 $this->logout();
605
606 throw new ApiException(
607 'Failed to refresh authentication token. Please log in again.',
608 $e->getCode(),
609 $e
610 );
611 }
612
613 $data = $this->parseResponse($response);
614
615 if (!isset($data['access_token'], $data['refresh_token'], $data['expires_in'])) {
616 throw new ApiException('refresh_token response invalid.');
617 }
618
619 $this->storeUserToken($data['access_token']);
620 $this->storeRefreshToken($data['refresh_token']);
621 $this->storeTokenExpires($data['expires_in']);
622 }
623
624 /**
625 * Release the wp_options lock after a token refresh.
626 */
627 private function releaseRefreshLock(): void
628 {
629 delete_option(self::OPTION_REFRESH_LOCK);
630 }
631
632 /**
633 * Decode a JSON response body into an array.
634 *
635 * @throws ApiException when the response body is empty or
636 * not valid JSON.
637 */
638 private function parseResponse(ResponseInterface $response): array
639 {
640 $response->getBody()->rewind();
641 $decoded = json_decode($response->getBody()->getContents(), true);
642
643 if (!is_array($decoded)) {
644 throw new ApiException('Invalid JSON response from the API.');
645 }
646
647 return $decoded;
648 }
649
650 /**
651 * Add userId and blogId to the URL as part of the authentication. When the
652 * userId and blogId are not set, they will not be added to the URL, which
653 * can still result in a successful request if the userToken is set and
654 * valid.
655 */
656 private function formatUrl(string $url): string
657 {
658 $query = http_build_query(array_filter([
659 'userId' => $this->userId,
660 'blogId' => $this->blogId,
661 ]));
662
663 // Dirty hack to allow for non-standard query params
664 // Metricool API supports urls with the same parameter multiple times
665 // Example /v2/settings/users/:id?fields=alternativeEmail&fields=sendToAlternativeEmail
666 $url = (strpos($url, '?') === false)
667 ? $url . '?' . $query
668 : $url . '&' . $query;
669
670 return trailingslashit($this->apiUrl) . $url;
671 }
672
673 /**
674 * Validate if all prerequisites are met to use the client. We need at least
675 * the user token to be set before we can make any requests.
676 * @throws \InvalidArgumentException
677 */
678 public function validate(): void
679 {
680 $validationErrors = [];
681
682 if ($this->hasAuthentication() === false) {
683 $validationErrors[] = 'Authentication is required for Metricool API.';
684 }
685
686 if ($this->isConnected() === false) {
687 $validationErrors[] = 'Client is not connected to Metricool API.';
688 }
689
690 if (!empty($validationErrors)) {
691 throw new InvalidArgumentException(
692 'Metricool Client is not setup correctly: ' . PHP_EOL .
693 esc_html(implode(', ', $validationErrors))
694 );
695 }
696 }
697 }
698