PluginProbe
Metricool – Social media and site statistics / 2.1.1
Metricool – Social media and site statistics v2.1.1
2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.27 trunk
← All changes | app/Http/Metricool/MetricoolClient.php +656 -0 2.0.1 → 2.1.1 View file →
@@ -1,0 +1,656 @@
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 +}