PluginProbe
Metricool – Social media and site statistics / trunk
Metricool – Social media and site statistics vtrunk
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 trunk, at app/Http/Metricool/MetricoolClient.php

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