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 +108 -149 2.0.2 → 2.1.1 View file →
@@ -3,19 +3,18 @@
3 3 declare(strict_types=1);
4 4
5 5 namespace Metricool\Http\Metricool;
6 6
7 -use Carbon\Carbon;
8 -use RuntimeException;
9 -use GuzzleHttp\Client;
10 -use GuzzleHttp\HandlerStack;
11 -use GuzzleHttp\Psr7\Request;
12 7 use InvalidArgumentException;
8 +use Metricool\Vendor\Carbon\Carbon;
9 +use Metricool\Vendor\GuzzleHttp\Client;
10 +use Metricool\Vendor\GuzzleHttp\Psr7\Request;
13 11 use Metricool\Services\OptionsService;
14 -use Psr\Http\Message\ResponseInterface;
15 -use GuzzleHttp\Exception\GuzzleException;
12 +use Metricool\Services\TrackingScriptService;
13 +use Metricool\Vendor\Psr\Http\Message\ResponseInterface;
16 14 use Metricool\Http\Metricool\Exceptions\ApiException;
17 15 use Metricool\Support\Helpers\Storages\EnvironmentConfig;
16 +use RuntimeException;
18 17 use Throwable;
19 18
20 19 class MetricoolClient
21 20 {
@@ -25,21 +24,23 @@
25 24 private const OPTION_REFRESH_TOKEN = 'metricool_refresh_token';
26 25 private const OPTION_AUTH_TOKEN_EXPIRES = 'metricool_auth_token_expires';
27 26 private const OPTION_REFRESH_LOCK = 'metricool_refresh_lock';
28 27
29 - private const LOCK_STALE_MS = 15000;
30 - private const LOCK_WAIT_SLEEP_MS = 100;
31 - private const LOCK_WAIT_MAX_MS = 5000;
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;
32 36
33 - private ?Client $client = null;
37 + private Client $client;
34 38
35 39 private EnvironmentConfig $env;
36 40 private OptionsService $options;
37 41
38 42 private string $apiUrl;
39 - private string $userToken = '';
40 - private string $blogId = '';
41 - private string $userId = '';
42 43 protected array $middleWares = [];
43 44
44 45
45 46 /**
@@ -49,8 +50,9 @@
49 50 {
50 51 $this->env = $env;
51 52 $this->options = $options;
52 53 $this->apiUrl = $env->get('metricool.base_api_domain');
54 + $this->client = $this->client();
53 55 }
54 56
55 57 /**
56 58 * Set the authenticated Metricool user ID.
@@ -56,17 +58,17 @@
56 58 * Set the authenticated Metricool user ID.
57 59 */
58 60 public function setUserId(string $userId): void
59 61 {
60 - $this->userId = $userId;
62 + update_option(self::OPTION_USER_ID, $userId, false);
61 63 }
62 64
63 65 /**
64 66 * Get the authenticated Metricool user ID.
65 67 */
66 - public function getUserId(): string
68 + public function getUserId(): ?string
67 69 {
68 - return $this->userId;
70 + return get_option(self::OPTION_USER_ID, null);
69 71 }
70 72
71 73 /**
72 74 * Check whether a Metricool user ID is available.
@@ -72,9 +74,9 @@
72 74 * Check whether a Metricool user ID is available.
73 75 */
74 76 public function hasUserId(): bool
75 77 {
76 - return !empty($this->userId);
78 + return !empty($this->getUserId());
77 79 }
78 80
79 81 /**
80 82 * Persist and set the Metricool user ID.
@@ -80,11 +82,9 @@
80 82 * Persist and set the Metricool user ID.
81 83 */
82 84 public function storeUserId(string $userId): void
83 85 {
84 - update_option(self::OPTION_USER_ID, $userId);
85 -
86 - $this->setUserId($userId);
86 + update_option(self::OPTION_USER_ID, $userId, false);
87 87 }
88 88
89 89 /**
90 90 * Clear the persisted Metricool user ID.
@@ -91,36 +91,24 @@
91 91 */
92 92 public function clearUserId(): void
93 93 {
94 94 delete_option(self::OPTION_USER_ID);
95 -
96 - $this->setUserId('');
97 95 }
98 96
99 97 /**
100 98 * Get the selected Metricool blog ID.
101 99 */
102 - public function getBlogId(): string
100 + public function getBlogId(): ?string
103 101 {
104 - return $this->blogId;
102 + return get_option(self::OPTION_BLOG_ID, null);
105 103 }
106 104
107 105 /**
108 - * Set the selected Metricool blog ID.
109 - */
110 - public function setBlogId(string $blogId): void
111 - {
112 - $this->blogId = $blogId;
113 - }
114 -
115 - /**
116 106 * Persist and set the Metricool blog ID.
117 107 */
118 108 public function storeBlogId(string $blogId): void
119 109 {
120 - update_option(self::OPTION_BLOG_ID, $blogId);
121 -
122 - $this->setBlogId($blogId);
110 + update_option(self::OPTION_BLOG_ID, $blogId, false);
123 111 }
124 112
125 113 /**
126 114 * Clear the persisted Metricool blog ID.
@@ -127,10 +115,8 @@
127 115 */
128 116 public function clearBlogId(): void
129 117 {
130 118 delete_option(self::OPTION_BLOG_ID);
131 -
132 - $this->setBlogId('');
133 119 }
134 120
135 121 /**
136 122 * Check whether a Metricool blog ID is available.
@@ -136,33 +122,25 @@
136 122 * Check whether a Metricool blog ID is available.
137 123 */
138 124 public function hasBlogId(): bool
139 125 {
140 - return !empty($this->blogId);
126 + return !empty($this->getBlogId());
141 127 }
142 128
143 129 /**
144 130 * Get the current access token.
145 131 */
146 - public function getUserToken(): string
132 + public function getUserToken(): ?string
147 133 {
148 - return $this->userToken;
134 + return get_option(self::OPTION_AUTH_TOKEN, null);
149 135 }
150 136
151 137 /**
152 - * Set the current access token.
153 - */
154 - public function setUserToken(string $userToken): void
155 - {
156 - $this->userToken = $userToken;
157 - }
158 -
159 - /**
160 138 * Check whether an access token is available.
161 139 */
162 140 public function hasUserToken(): bool
163 141 {
164 - return !empty($this->userToken);
142 + return !empty($this->getUserToken());
165 143 }
166 144
167 145 /**
168 146 * Persist and set the current access token.
@@ -168,11 +146,9 @@
168 146 * Persist and set the current access token.
169 147 */
170 148 public function storeUserToken(string $token): void
171 149 {
172 - update_option(self::OPTION_AUTH_TOKEN, $token);
173 -
174 - $this->setUserToken($token);
150 + update_option(self::OPTION_AUTH_TOKEN, $token, false);
175 151 }
176 152
177 153 /**
178 154 * Clear the persisted access token.
@@ -179,18 +155,24 @@
179 155 */
180 156 public function clearUserToken(): void
181 157 {
182 158 delete_option(self::OPTION_AUTH_TOKEN);
183 -
184 - $this->setUserToken('');
185 159 }
186 160
187 161 /**
188 162 * Get the persisted refresh token.
189 163 */
190 - public function getRefreshToken(): string
164 + public function getRefreshToken(): ?string
191 165 {
192 - return get_option(self::OPTION_REFRESH_TOKEN);
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 + );
193 175 }
194 176
195 177 /**
196 178 * Persist the refresh token.
@@ -196,9 +178,9 @@
196 178 * Persist the refresh token.
197 179 */
198 180 public function storeRefreshToken(string $refreshToken): void
199 181 {
200 - update_option(self::OPTION_REFRESH_TOKEN, $refreshToken);
182 + update_option(self::OPTION_REFRESH_TOKEN, $refreshToken, false);
201 183 }
202 184
203 185 /**
204 186 * Clear the persisted refresh token data.
@@ -220,9 +202,9 @@
220 202
221 203 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
222 204 return (int) $wpdb->get_var(
223 205 $wpdb->prepare(
224 - "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
206 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
225 207 self::OPTION_AUTH_TOKEN_EXPIRES
226 208 )
227 209 );
228 210 }
@@ -250,36 +232,12 @@
250 232 public function storeTokenExpires(int $expiresIn): void
251 233 {
252 234 $expiresIn = Carbon::now()->addSeconds($expiresIn)->timestamp;
253 235
254 - update_option(self::OPTION_AUTH_TOKEN_EXPIRES, $expiresIn);
236 + update_option(self::OPTION_AUTH_TOKEN_EXPIRES, $expiresIn, false);
255 237 }
256 238
257 239 /**
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 240 * Set the authentication tokens and userId.
283 241 */
284 242 public function authenticate(string $userId, string $userToken, string $refreshToken, int $expires): self
285 243 {
@@ -299,25 +257,25 @@
299 257 $this->options->wipe();
300 258 }
301 259
302 260 /**
303 - * Check if the client has all the necessary authentication tokens to show the dashboard
261 + * Clear the authentication tokens and userId, but keep the tracking
262 + * widget active so the website does not lose data.
304 263 */
305 - public function hasAuthentication(): bool
264 + public function logoutPreservingTracking(): void
306 265 {
307 - return $this->hasUserToken() && $this->hasUserId();
266 + $this->options->wipe(false, [
267 + TrackingScriptService::OPTION_TRACKING_HASH,
268 + TrackingScriptService::OPTION_TRACKING_ACTIVE,
269 + ]);
308 270 }
309 271
310 272 /**
311 - * Build the middleware stack for the HTTP client.
273 + * Check if the client has all the necessary authentication tokens to show the dashboard.
312 274 */
313 - protected function middleware(): HandlerStack
275 + public function hasAuthentication(): bool
314 276 {
315 - $handlerStack = HandlerStack::create();
316 - foreach ($this->middleWares as $middleWare) {
317 - $handlerStack->push($middleWare);
318 - }
319 - return $handlerStack;
277 + return $this->hasUserToken() && $this->hasUserId();
320 278 }
321 279
322 280 /**
323 281 * Build or return the configured HTTP client instance.
@@ -323,15 +281,10 @@
323 281 * Build or return the configured HTTP client instance.
324 282 */
325 283 private function client(): Client
326 284 {
327 - if ($this->client) {
328 - return $this->client;
329 - }
330 -
331 - $this->client = new Client([
285 + return new Client([
332 286 'http_errors' => true,
333 - 'handler' => $this->middleware(),
334 287 'expect' => false,
335 288 'headers' => [
336 289 'Accept' => 'application/json',
337 290 'Content-Type' => 'application/json',
@@ -337,10 +290,8 @@
337 290 'Content-Type' => 'application/json',
338 291 'User-Agent' => $this->getRequestUserAgent(),
339 292 ]
340 293 ]);
341 -
342 - return $this->client;
343 294 }
344 295
345 296 /**
346 297 * Get the user agent string for the request.
@@ -420,16 +371,12 @@
420 371
421 372 try {
422 373 $response = $this->client->send(
423 374 new Request($method, $this->formatUrl($endpoint), [
424 - 'Authorization' => 'Bearer ' . $this->userToken
375 + 'Authorization' => 'Bearer ' . $this->getUserToken()
425 376 ], json_encode($body))
426 377 );
427 378 } catch (Throwable $e) {
428 - if ($e instanceof GuzzleException && $e->getCode() === 401) {
429 - $this->logout();
430 - }
431 -
432 379 throw new ApiException(
433 380 $e->getMessage(),
434 381 $e->getCode(),
435 382 $e
@@ -472,9 +419,15 @@
472 419 $e
473 420 );
474 421 }
475 422
476 - return $this->parseResponse($response);
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;
477 430 }
478 431
479 432 /**
480 433 * Refresh the authentication token using the refresh token.
@@ -489,8 +442,9 @@
489 442 */
490 443 public function refreshAuthToken(): void
491 444 {
492 445 $lockAcquired = $this->lockTokenRefresh();
446 +
493 447 if ($lockAcquired === false) {
494 448 $this->pollForNewUserToken();
495 449 return;
496 450 }
@@ -515,9 +469,9 @@
515 469 $wpdb->query(
516 470 $wpdb->prepare(
517 471 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value < %d",
518 472 self::OPTION_REFRESH_LOCK,
519 - time() - (int) (self::LOCK_STALE_MS / 1000)
473 + time() - $this->getRefreshLockTimeoutSeconds()
520 474 )
521 475 );
522 476
523 477 // Attempt to insert the lock row. INSERT IGNORE ensures only one process succeeds when racing concurrently.
@@ -533,46 +487,49 @@
533 487 return ($result !== false && $result > 0);
534 488 }
535 489
536 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 + /**
537 499 * Wait for another process to refresh the token by polling if the token is expired
538 500 * @throws RuntimeException if the token is still expired after waiting for the maximum time.
539 501 */
540 502 private function pollForNewUserToken(): void
541 503 {
542 - $maxWait = self::LOCK_WAIT_MAX_MS;
543 - $sleepDuration = self::LOCK_WAIT_SLEEP_MS;
544 - $waited = 0;
504 + $maxWaitMs = self::REFRESH_TIMEOUT_SECONDS * 1000;
505 + $sleepDurationMs = self::REFRESH_LOCK_WAIT_MS;
506 + $waitedMs = 0;
545 507
546 - while ($waited < $maxWait) {
508 + while ($waitedMs < $maxWaitMs) {
547 509 if ($this->isTokenExpired() === false) {
548 - $this->setUserToken($this->fetchUserToken());
510 + $this->clearTokenOptionCache();
549 511 return;
550 512 }
551 513
552 - usleep($sleepDuration * 1000);
553 - $waited += $sleepDuration;
514 + usleep($sleepDurationMs * 1000);
515 + $waitedMs += $sleepDurationMs;
554 516 }
555 517
556 - throw new \RuntimeException('Timed out waiting for token refresh. Please try again.');
518 + throw new RuntimeException('Timed out waiting for token refresh. Please try again.');
557 519 }
558 520
559 521 /**
560 - * Read the access token directly from the database, bypassing the
561 - * WordPress object cache.
522 + * Remove the token options from the WordPress object cache so the
523 + * next read fetches the freshly refreshed values from the database.
562 524 */
563 - private function fetchUserToken(): string
525 + private function clearTokenOptionCache(): void
564 526 {
565 - global $wpdb;
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 + }
566 531
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 532
576 533 /**
577 534 * Perform the actual token refresh request against the
578 535 * Metricool OAuth endpoint.
@@ -581,28 +538,34 @@
581 538 * response is invalid.
582 539 */
583 540 private function performTokenRefresh(): void
584 541 {
585 - $headers = [
586 - 'Accept' => 'application/json',
587 - 'Content-Type' => 'application/x-www-form-urlencoded',
588 - ];
542 + $refreshToken = $this->getRefreshToken();
589 543
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 - ];
544 + if (empty($refreshToken)) {
545 + $this->logoutPreservingTracking();
546 + throw new RuntimeException('No refresh token available, the user has been logged out.');
547 + }
597 548
598 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 +
599 559 $response = $this->client->send(
600 - new Request('POST', $this->env->getString('metricool.oauth_token_url'), $headers),
560 + new Request('POST', $this->env->getString('metricool.oauth_token_url'), [
561 + 'Accept' => 'application/json',
562 + 'Content-Type' => 'application/x-www-form-urlencoded',
563 + ]),
601 564 $options
602 565 );
603 566 } catch (Throwable $e) {
604 - $this->logout();
567 + $this->logoutPreservingTracking();
605 568
606 569 throw new ApiException(
607 570 'Failed to refresh authentication token. Please log in again.',
608 571 $e->getCode(),
@@ -655,10 +618,10 @@
655 618 */
656 619 private function formatUrl(string $url): string
657 620 {
658 621 $query = http_build_query(array_filter([
659 - 'userId' => $this->userId,
660 - 'blogId' => $this->blogId,
622 + 'userId' => $this->getUserId(),
623 + 'blogId' => $this->getBlogId(),
661 624 ]));
662 625
663 626 // Dirty hack to allow for non-standard query params
664 627 // Metricool API supports urls with the same parameter multiple times
@@ -672,9 +635,9 @@
672 635
673 636 /**
674 637 * Validate if all prerequisites are met to use the client. We need at least
675 638 * the user token to be set before we can make any requests.
676 - * @throws \InvalidArgumentException
639 + * @throws InvalidArgumentException
677 640 */
678 641 public function validate(): void
679 642 {
680 643 $validationErrors = [];
@@ -680,12 +643,8 @@
680 643 $validationErrors = [];
681 644
682 645 if ($this->hasAuthentication() === false) {
683 646 $validationErrors[] = 'Authentication is required for Metricool API.';
684 - }
685 -
686 - if ($this->isConnected() === false) {
687 - $validationErrors[] = 'Client is not connected to Metricool API.';
688 647 }
689 648
690 649 if (!empty($validationErrors)) {
691 650 throw new InvalidArgumentException(