PluginProbe
The Innovative Form Builder – IvyForms / 1.5.1
The Innovative Form Builder – IvyForms v1.5.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Services / GoogleSheets / GoogleSheetsService.php

GoogleSheetsService.php in The Innovative Form Builder – IvyForms 1.5.1, at backend/src/Services/GoogleSheets/GoogleSheetsService.php

826 lines 25.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace IvyForms\Services\GoogleSheets;
4
5 use IvyForms\Services\Settings\SettingsService;
6 use IvyForms\Services\Translations\BackendStrings;
7
8 /**
9 * Google Sheets API service — site token storage + Sheets/Drive usage.
10 * OAuth app credentials and code/token exchange live in middleware.
11 *
12 * @SuppressWarnings(PHPMD)
13 */
14 class GoogleSheetsService
15 {
16 private const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
17 private const DRIVE_FILES_URL = 'https://www.googleapis.com/drive/v3/files';
18 private const SHEETS_API_URL = 'https://sheets.googleapis.com/v4/spreadsheets';
19 private const PICKER_CONFIG_TRANSIENT = 'ivyforms_google_sheets_picker_config';
20 public const PICKER_NOT_CONFIGURED_CODE = 'google_sheets_picker_not_configured';
21 public const PICKER_ERROR_CODE = 'google_sheets_picker_error';
22
23 private SettingsService $settingsService;
24
25 public function __construct(SettingsService $settingsService)
26 {
27 $this->settingsService = $settingsService;
28 }
29
30 public function hasCredentials(): bool
31 {
32 return $this->isOAuthConnection();
33 }
34
35 public function isOAuthAppConfigured(): bool
36 {
37 return $this->getMiddlewareApiBaseUrl() !== '';
38 }
39
40 public function createAuthUrl(?string $oauthState = null): string
41 {
42 $state = $oauthState ?? (defined('IVYFORMS_SITE_URL') ? (string) IVYFORMS_SITE_URL : (string) home_url());
43
44 return $this->fetchMiddlewareAuthorizationUrl($state);
45 }
46
47 public function getOAuthNotConfiguredMessage(): string
48 {
49 return BackendStrings::getIntegrationsStrings()['google_sheets_oauth_not_configured'];
50 }
51
52 public function getMiddlewareUnreachableMessage(): string
53 {
54 return BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'];
55 }
56
57 public function isMiddlewareReachable(): bool
58 {
59 $baseUrl = $this->getMiddlewareApiBaseUrl();
60
61 if ($baseUrl === '') {
62 return false;
63 }
64
65 $response = wp_remote_get(
66 $baseUrl . '/google-sheets/redirect',
67 [
68 'timeout' => 5,
69 'redirection' => 0,
70 ]
71 );
72
73 if (is_wp_error($response)) {
74 return false;
75 }
76
77 $statusCode = (int) wp_remote_retrieve_response_code($response);
78
79 return $statusCode >= 200 && $statusCode < 500;
80 }
81
82 /**
83 * Exchange a one-time middleware handoff key for OAuth tokens (server-side POST).
84 *
85 * @return array{
86 * success: bool,
87 * message: string,
88 * accessToken?: string,
89 * refreshToken?: string,
90 * expiresIn?: int,
91 * email?: string
92 * }
93 */
94 public function exchangeMiddlewareHandoff(string $exchangeKey): array
95 {
96 $exchangeKey = sanitize_text_field($exchangeKey);
97
98 if ($exchangeKey === '') {
99 return [
100 'success' => false,
101 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'],
102 ];
103 }
104
105 $url = $this->getMiddlewareApiBaseUrl() . '/google-sheets/oauth/exchange';
106
107 if ($url === '/google-sheets/oauth/exchange') {
108 return [
109 'success' => false,
110 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'],
111 ];
112 }
113
114 $response = wp_remote_post($url, [
115 'body' => ['exchange' => $exchangeKey],
116 'timeout' => 30,
117 ]);
118
119 if (is_wp_error($response)) {
120 return [
121 'success' => false,
122 'message' => $response->get_error_message(),
123 ];
124 }
125
126 $statusCode = (int) wp_remote_retrieve_response_code($response);
127 $body = json_decode((string) wp_remote_retrieve_body($response), true);
128
129 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body)) {
130 $message = is_array($body) && !empty($body['message'])
131 ? (string) $body['message']
132 : BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'];
133
134 return [
135 'success' => false,
136 'message' => $message,
137 ];
138 }
139
140 $accessToken = (string) ($body['accessToken'] ?? $body['access_token'] ?? '');
141 $refreshToken = (string) ($body['refreshToken'] ?? $body['refresh_token'] ?? '');
142
143 if ($accessToken === '' || $refreshToken === '') {
144 return [
145 'success' => false,
146 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'],
147 ];
148 }
149
150 return [
151 'success' => true,
152 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_success'],
153 'accessToken' => $accessToken,
154 'refreshToken' => $refreshToken,
155 'expiresIn' => (int) ($body['expiresIn'] ?? $body['expires_in'] ?? 3600),
156 'email' => sanitize_email((string) ($body['email'] ?? '')),
157 ];
158 }
159
160 /**
161 * Persist tokens received from the OAuth middleware callback.
162 */
163 public function saveOAuthTokens(
164 string $accessToken,
165 string $refreshToken,
166 int $expiresIn,
167 string $email = ''
168 ): void {
169 $settings = $this->getSettings();
170
171 if ($email === '') {
172 $email = $this->fetchUserEmail($accessToken);
173 }
174
175 $settings['authType'] = 'oauth';
176 $settings['accessToken'] = $accessToken;
177 $settings['refreshToken'] = $refreshToken;
178 $settings['tokenExpiresAt'] = time() + max(60, $expiresIn - 30);
179 $settings['accountEmail'] = $email;
180 $settings['connected'] = true;
181 $settings['enabled'] = true;
182
183 $this->saveSettings($settings);
184 }
185
186 public function isConnected(): bool
187 {
188 $settings = $this->getSettings();
189
190 return $this->isOAuthConnection() && !empty($settings['refreshToken']);
191 }
192
193 /**
194 * @return array<string, mixed>
195 */
196 public function getSettings(): array
197 {
198 return $this->settingsService->getSetting('integrations', 'google_sheets') ?? [];
199 }
200
201 /**
202 * @param array<string, mixed> $settings
203 */
204 public function saveSettings(array $settings): void
205 {
206 $this->settingsService->setSetting('integrations', 'google_sheets', $settings);
207 }
208
209 public function disconnect(): void
210 {
211 $settings = $this->getSettings();
212
213 $tokenToRevoke = (string) ($settings['refreshToken'] ?? '');
214 if ($tokenToRevoke === '') {
215 $tokenToRevoke = (string) ($settings['accessToken'] ?? '');
216 }
217
218 if ($tokenToRevoke !== '') {
219 $this->revokeOAuthToken($tokenToRevoke);
220 }
221
222 $settings['accessToken'] = '';
223 $settings['tokenExpiresAt'] = 0;
224 $settings['refreshToken'] = '';
225 $settings['connected'] = false;
226 $settings['accountEmail'] = '';
227 $settings['authType'] = '';
228
229 $this->saveSettings($settings);
230 }
231
232 private function revokeOAuthToken(string $token): void
233 {
234 wp_remote_post('https://oauth2.googleapis.com/revoke', [
235 'headers' => [
236 'Content-Type' => 'application/x-www-form-urlencoded',
237 ],
238 'body' => http_build_query(['token' => $token]),
239 'timeout' => 15,
240 ]);
241 }
242
243 /**
244 * Legacy Drive listing. Spreadsheet selection uses the Google Picker, which
245 * only grants drive.file access to files the user picks. This query does not
246 * see those files and often returns an empty list. Kept for older clients.
247 *
248 * @return array<array{id: string, name: string}>
249 * @deprecated Use the Picker flow (picker-config) instead of listing Drive files.
250 */
251 public function listSpreadsheets(): array
252 {
253 $url = add_query_arg(
254 [
255 'q' => "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false",
256 'fields' => 'files(id,name)',
257 'pageSize' => 100,
258 ],
259 self::DRIVE_FILES_URL
260 );
261
262 $response = $this->apiRequest('GET', $url);
263
264 if (!$response['success']) {
265 return [];
266 }
267
268 $data = json_decode($response['body'], true);
269 $files = $data['files'] ?? [];
270
271 return array_map(static function ($file) {
272 return [
273 'id' => (string) ($file['id'] ?? ''),
274 'name' => (string) ($file['name'] ?? ''),
275 ];
276 }, is_array($files) ? $files : []);
277 }
278
279 /**
280 * @return array<array{title: string, sheet_id: int}>
281 */
282 public function listWorksheets(string $spreadsheetId): array
283 {
284 $url = self::SHEETS_API_URL . '/' . rawurlencode($spreadsheetId) . '?fields=sheets.properties';
285
286 $response = $this->apiRequest('GET', $url);
287
288 if (!$response['success']) {
289 return [];
290 }
291
292 $data = json_decode($response['body'], true);
293 $sheets = $data['sheets'] ?? [];
294 $result = [];
295
296 foreach (is_array($sheets) ? $sheets : [] as $sheet) {
297 $properties = $sheet['properties'] ?? [];
298 if (empty($properties['title'])) {
299 continue;
300 }
301
302 $result[] = [
303 'title' => (string) $properties['title'],
304 'sheet_id' => (int) ($properties['sheetId'] ?? 0),
305 ];
306 }
307
308 return $result;
309 }
310
311 /**
312 * @return array<int, string>
313 */
314 public function listColumns(string $spreadsheetId, string $worksheetName): array
315 {
316 $range = $this->buildRange($worksheetName, '1:1');
317 $url = self::SHEETS_API_URL . '/' . rawurlencode($spreadsheetId) . '/values/' . rawurlencode($range);
318
319 $response = $this->apiRequest('GET', $url);
320
321 if (!$response['success']) {
322 return [];
323 }
324
325 $data = json_decode($response['body'], true);
326 $values = $data['values'][0] ?? [];
327
328 if (!is_array($values)) {
329 return [];
330 }
331
332 return array_values(array_filter(array_map(static function ($value) {
333 return trim((string) $value);
334 }, $values), static function ($value) {
335 return $value !== '';
336 }));
337 }
338
339 /**
340 * @param array<int, string> $rowValues
341 * @return array{success: bool, message: string, status_code: int|null}
342 */
343 public function appendRow(string $spreadsheetId, string $worksheetName, array $rowValues): array
344 {
345 $range = $this->buildRange($worksheetName, 'A:Z');
346 $url = self::SHEETS_API_URL . '/' . rawurlencode($spreadsheetId)
347 . '/values/' . rawurlencode($range) . ':append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS';
348
349 $response = $this->apiRequest('POST', $url, [
350 'values' => [array_values($rowValues)],
351 ]);
352
353 if ($response['success']) {
354 return [
355 'success' => true,
356 'message' => 'Row appended successfully',
357 'status_code' => $response['status_code'],
358 ];
359 }
360
361 return [
362 'success' => false,
363 'message' => $response['message'],
364 'status_code' => $response['status_code'],
365 ];
366 }
367
368 /**
369 * @param array<string, mixed>|null $body
370 * @return array{success: bool, status_code: int|null, body: string, message: string}
371 */
372 private function apiRequest(string $method, string $url, ?array $body = null, ?string $accessToken = null): array
373 {
374 $token = $accessToken ?? $this->getValidAccessToken();
375
376 if ($token === null) {
377 return [
378 'success' => false,
379 'status_code' => null,
380 'body' => '',
381 'message' => 'Google Sheets is not connected',
382 ];
383 }
384
385 $args = [
386 'method' => $method,
387 'headers' => [
388 'Authorization' => 'Bearer ' . $token,
389 'Content-Type' => 'application/json',
390 ],
391 'timeout' => 30,
392 ];
393
394 if ($body !== null) {
395 $args['body'] = wp_json_encode($body);
396 }
397
398 $response = wp_remote_request($url, $args);
399
400 if (is_wp_error($response)) {
401 return [
402 'success' => false,
403 'status_code' => null,
404 'body' => '',
405 'message' => $response->get_error_message(),
406 ];
407 }
408
409 $statusCode = (int) wp_remote_retrieve_response_code($response);
410 $responseBody = (string) wp_remote_retrieve_body($response);
411
412 if ($statusCode === 401 && $accessToken === null) {
413 $refreshed = $this->refreshAccessToken();
414 if ($refreshed) {
415 return $this->apiRequest($method, $url, $body, $this->getValidAccessToken());
416 }
417 }
418
419 $success = $statusCode >= 200 && $statusCode < 300;
420
421 return [
422 'success' => $success,
423 'status_code' => $statusCode,
424 'body' => $responseBody,
425 'message' => $success ? 'OK' : $this->extractErrorMessage($responseBody, $statusCode),
426 ];
427 }
428
429 private function getValidAccessToken(): ?string
430 {
431 $settings = $this->getSettings();
432 $accessToken = (string) ($settings['accessToken'] ?? '');
433 $expiresAt = (int) ($settings['tokenExpiresAt'] ?? 0);
434
435 if ($accessToken === '') {
436 return null;
437 }
438
439 if ($expiresAt > time() + 60) {
440 return $accessToken;
441 }
442
443 if (!$this->refreshAccessToken()) {
444 return null;
445 }
446
447 $settings = $this->getSettings();
448 $accessToken = (string) ($settings['accessToken'] ?? '');
449
450 return $accessToken !== '' ? $accessToken : null;
451 }
452
453 private function refreshAccessToken(): bool
454 {
455 $tokenResult = $this->fetchOAuthAccessToken(true);
456
457 if (!$tokenResult['success']) {
458 return false;
459 }
460
461 $settings = $this->getSettings();
462 $settings['accessToken'] = $tokenResult['access_token'];
463 $settings['tokenExpiresAt'] = $tokenResult['expires_at'];
464 $settings['connected'] = true;
465
466 $this->saveSettings($settings);
467
468 return true;
469 }
470
471 /**
472 * @return array{success: bool, message: string, access_token?: string, expires_at?: int}
473 */
474 private function fetchOAuthAccessToken(bool $forceRefresh): array
475 {
476 $settings = $this->getSettings();
477 $accessToken = (string) ($settings['accessToken'] ?? '');
478 $expiresAt = (int) ($settings['tokenExpiresAt'] ?? 0);
479
480 if (!$forceRefresh && $accessToken !== '' && $expiresAt > time() + 60) {
481 return [
482 'success' => true,
483 'message' => 'OK',
484 'access_token' => $accessToken,
485 'expires_at' => $expiresAt,
486 ];
487 }
488
489 $refreshToken = (string) ($settings['refreshToken'] ?? '');
490
491 if ($refreshToken === '') {
492 return [
493 'success' => false,
494 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_credentials_missing'],
495 ];
496 }
497
498 $response = wp_remote_post($this->getMiddlewareRefreshUrl(), [
499 'headers' => [
500 'Content-Type' => 'application/json',
501 ],
502 'body' => wp_json_encode([
503 'refresh_token' => $refreshToken,
504 'site_url' => IVYFORMS_SITE_URL,
505 ]),
506 'timeout' => 30,
507 ]);
508
509 if (is_wp_error($response)) {
510 return [
511 'success' => false,
512 'message' => $response->get_error_message(),
513 ];
514 }
515
516 $statusCode = (int) wp_remote_retrieve_response_code($response);
517 $body = json_decode((string) wp_remote_retrieve_body($response), true);
518
519 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body) || empty($body['access_token'])) {
520 $message = is_array($body) && !empty($body['error_description'])
521 ? (string) $body['error_description']
522 : (is_array($body) && !empty($body['message'])
523 ? (string) $body['message']
524 : 'Failed to refresh Google access token');
525
526 return [
527 'success' => false,
528 'message' => $message,
529 ];
530 }
531
532 $expiresIn = (int) ($body['expires_in'] ?? 3600);
533
534 return [
535 'success' => true,
536 'message' => 'OK',
537 'access_token' => (string) $body['access_token'],
538 'expires_at' => time() + max(60, $expiresIn - 30),
539 ];
540 }
541
542 private function isOAuthConnection(): bool
543 {
544 $settings = $this->getSettings();
545
546 return !empty($settings['refreshToken'])
547 || (($settings['authType'] ?? '') === 'oauth' && !empty($settings['accessToken']));
548 }
549
550 private function getMiddlewareBaseUrl(): string
551 {
552 if (defined('IVYFORMS_GOOGLE_SHEETS_MIDDLEWARE_URL') && IVYFORMS_GOOGLE_SHEETS_MIDDLEWARE_URL !== '') {
553 return rtrim((string) IVYFORMS_GOOGLE_SHEETS_MIDDLEWARE_URL, '/');
554 }
555
556 $filtered = apply_filters('ivyforms/google_sheets/middleware_url', '');
557
558 return is_string($filtered) ? rtrim($filtered, '/') : '';
559 }
560
561 /**
562 * Picker credentials from middleware (cached) plus a short-lived access token
563 * for the Google Picker widget.
564 *
565 * @return array{
566 * success: bool,
567 * code?: string,
568 * message?: string,
569 * developerKey?: string,
570 * appId?: string,
571 * accessToken?: string
572 * }
573 */
574 public function getPickerConfig(): array
575 {
576 $accessToken = $this->getValidAccessToken();
577 if ($accessToken === null) {
578 return [
579 'success' => false,
580 'code' => self::PICKER_ERROR_CODE,
581 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_not_connected'],
582 ];
583 }
584
585 $pickerKeys = $this->getCachedOrFetchedPickerKeys();
586 if (empty($pickerKeys['success'])) {
587 $code = (string) ($pickerKeys['code'] ?? self::PICKER_ERROR_CODE);
588
589 return [
590 'success' => false,
591 'code' => $code,
592 'message' => $code === self::PICKER_NOT_CONFIGURED_CODE
593 ? BackendStrings::getIntegrationsStrings()['google_sheets_picker_not_configured']
594 : BackendStrings::getIntegrationsStrings()['google_sheets_picker_error'],
595 ];
596 }
597
598 return [
599 'success' => true,
600 'developerKey' => $pickerKeys['developerKey'],
601 'appId' => $pickerKeys['appId'],
602 'accessToken' => $accessToken,
603 ];
604 }
605
606 /**
607 * @return array{success: true, developerKey: string, appId: string}|array{success: false, code: string}
608 */
609 private function getCachedOrFetchedPickerKeys(): array
610 {
611 $cached = get_transient(self::PICKER_CONFIG_TRANSIENT);
612 if (is_array($cached)) {
613 $developerKey = trim((string) ($cached['developerKey'] ?? ''));
614 $appId = trim((string) ($cached['appId'] ?? ''));
615 if ($developerKey !== '' && $appId !== '') {
616 return [
617 'success' => true,
618 'developerKey' => $developerKey,
619 'appId' => $appId,
620 ];
621 }
622 }
623
624 $fetched = $this->fetchMiddlewarePickerConfig();
625 if (!empty($fetched['success'])) {
626 set_transient(self::PICKER_CONFIG_TRANSIENT, [
627 'developerKey' => $fetched['developerKey'],
628 'appId' => $fetched['appId'],
629 ], DAY_IN_SECONDS);
630 }
631
632 return $fetched;
633 }
634
635 /**
636 * @return array{success: true, developerKey: string, appId: string}|array{success: false, code: string}
637 */
638 private function fetchMiddlewarePickerConfig(): array
639 {
640 $baseUrl = $this->getMiddlewareApiBaseUrl();
641 if ($baseUrl === '') {
642 return [
643 'success' => false,
644 'code' => self::PICKER_ERROR_CODE,
645 ];
646 }
647
648 $response = wp_remote_get(
649 $baseUrl . '/google-sheets/picker-config',
650 ['timeout' => 15]
651 );
652
653 if (is_wp_error($response)) {
654 return [
655 'success' => false,
656 'code' => self::PICKER_ERROR_CODE,
657 ];
658 }
659
660 $statusCode = (int) wp_remote_retrieve_response_code($response);
661 $body = json_decode((string) wp_remote_retrieve_body($response), true);
662 $developerKey = '';
663 $appId = '';
664 if (is_array($body)) {
665 $developerKey = trim((string) ($body['developerKey'] ?? ''));
666 $appId = trim((string) ($body['appId'] ?? ''));
667 }
668
669 $keysMissing = $developerKey === '' || $appId === '';
670 if ($statusCode === 400 && $keysMissing) {
671 return [
672 'success' => false,
673 'code' => self::PICKER_NOT_CONFIGURED_CODE,
674 ];
675 }
676
677 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body)) {
678 return [
679 'success' => false,
680 'code' => self::PICKER_ERROR_CODE,
681 ];
682 }
683
684 if ($keysMissing) {
685 return [
686 'success' => false,
687 'code' => self::PICKER_NOT_CONFIGURED_CODE,
688 ];
689 }
690
691 return [
692 'success' => true,
693 'developerKey' => $developerKey,
694 'appId' => $appId,
695 ];
696 }
697
698 private function fetchMiddlewareAuthorizationUrl(string $state): string
699 {
700 $baseUrl = $this->getMiddlewareApiBaseUrl();
701
702 if ($baseUrl === '') {
703 return '';
704 }
705
706 $response = wp_remote_get(
707 $baseUrl . '/google-sheets/authorize-url?' . http_build_query(['state' => $state]),
708 ['timeout' => 15]
709 );
710
711 if (is_wp_error($response)) {
712 return '';
713 }
714
715 $statusCode = (int) wp_remote_retrieve_response_code($response);
716 $body = json_decode((string) wp_remote_retrieve_body($response), true);
717
718 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body)) {
719 return '';
720 }
721
722 return (string) ($body['auth_url'] ?? '');
723 }
724
725 private function getMiddlewareRefreshUrl(): string
726 {
727 return $this->getMiddlewareApiBaseUrl() . '/google-sheets/refresh';
728 }
729
730 /**
731 * Base URL for server-side middleware calls (token refresh).
732 * In Docker, localhost points at the container — use host.docker.internal when applicable.
733 */
734 private function getMiddlewareApiBaseUrl(): string
735 {
736 $baseUrl = $this->getMiddlewareBaseUrl();
737
738 if ($baseUrl === '') {
739 return '';
740 }
741
742 if (str_contains($baseUrl, '://localhost') && $this->isRunningInDockerContainer()) {
743 $baseUrl = (string) preg_replace(
744 '#^https?://localhost#i',
745 'http://host.docker.internal',
746 $baseUrl
747 );
748 }
749
750 /**
751 * @param string $baseUrl
752 */
753 return (string) apply_filters('ivyforms/google_sheets/middleware_api_url', $baseUrl);
754 }
755
756 /**
757 * Detect Docker without triggering open_basedir warnings on shared hosting.
758 */
759 private function isRunningInDockerContainer(): bool
760 {
761 $openBasedir = ini_get('open_basedir');
762 if (is_string($openBasedir) && $openBasedir !== '') {
763 $allowed = false;
764 foreach (explode(PATH_SEPARATOR, $openBasedir) as $root) {
765 $root = rtrim($root, '/\\');
766 if ($root !== '' && str_starts_with('/.dockerenv', $root)) {
767 $allowed = true;
768 break;
769 }
770 }
771
772 if (!$allowed) {
773 return false;
774 }
775 }
776
777 return file_exists('/.dockerenv');
778 }
779
780 private function fetchUserEmail(string $accessToken): string
781 {
782 $response = wp_remote_get(self::GOOGLE_USERINFO_URL, [
783 'headers' => [
784 'Authorization' => 'Bearer ' . $accessToken,
785 ],
786 'timeout' => 15,
787 ]);
788
789 if (is_wp_error($response)) {
790 return '';
791 }
792
793 $body = json_decode((string) wp_remote_retrieve_body($response), true);
794
795 if (!is_array($body)) {
796 return '';
797 }
798
799 return sanitize_email((string) ($body['email'] ?? ''));
800 }
801
802 private function extractErrorMessage(string $body, int $statusCode): string
803 {
804 $data = json_decode($body, true);
805
806 if (is_array($data)) {
807 if (!empty($data['error']['message'])) {
808 return (string) $data['error']['message'];
809 }
810
811 if (!empty($data['error_description'])) {
812 return (string) $data['error_description'];
813 }
814 }
815
816 return 'Google API request failed with status ' . $statusCode;
817 }
818
819 private function buildRange(string $worksheetName, string $cellRange): string
820 {
821 $escapedName = str_replace("'", "''", $worksheetName);
822
823 return "'" . $escapedName . "'!" . $cellRange;
824 }
825 }
826