PluginProbe
The Innovative Form Builder – IvyForms / 1.4
The Innovative Form Builder – IvyForms v1.4
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.4, at backend/src/Services/GoogleSheets/GoogleSheetsService.php

679 lines 20.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
20 private SettingsService $settingsService;
21
22 public function __construct(SettingsService $settingsService)
23 {
24 $this->settingsService = $settingsService;
25 }
26
27 public function hasCredentials(): bool
28 {
29 return $this->isOAuthConnection();
30 }
31
32 public function isOAuthAppConfigured(): bool
33 {
34 return $this->getMiddlewareApiBaseUrl() !== '';
35 }
36
37 public function createAuthUrl(?string $oauthState = null): string
38 {
39 $state = $oauthState ?? (defined('IVYFORMS_SITE_URL') ? (string) IVYFORMS_SITE_URL : (string) home_url());
40
41 return $this->fetchMiddlewareAuthorizationUrl($state);
42 }
43
44 public function getOAuthNotConfiguredMessage(): string
45 {
46 return BackendStrings::getIntegrationsStrings()['google_sheets_oauth_not_configured'];
47 }
48
49 public function getMiddlewareUnreachableMessage(): string
50 {
51 return BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'];
52 }
53
54 public function isMiddlewareReachable(): bool
55 {
56 $baseUrl = $this->getMiddlewareApiBaseUrl();
57
58 if ($baseUrl === '') {
59 return false;
60 }
61
62 $response = wp_remote_get(
63 $baseUrl . '/google-sheets/redirect',
64 [
65 'timeout' => 5,
66 'redirection' => 0,
67 ]
68 );
69
70 if (is_wp_error($response)) {
71 return false;
72 }
73
74 $statusCode = (int) wp_remote_retrieve_response_code($response);
75
76 return $statusCode >= 200 && $statusCode < 500;
77 }
78
79 /**
80 * Exchange a one-time middleware handoff key for OAuth tokens (server-side POST).
81 *
82 * @return array{
83 * success: bool,
84 * message: string,
85 * accessToken?: string,
86 * refreshToken?: string,
87 * expiresIn?: int,
88 * email?: string
89 * }
90 */
91 public function exchangeMiddlewareHandoff(string $exchangeKey): array
92 {
93 $exchangeKey = sanitize_text_field($exchangeKey);
94
95 if ($exchangeKey === '') {
96 return [
97 'success' => false,
98 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'],
99 ];
100 }
101
102 $url = $this->getMiddlewareApiBaseUrl() . '/google-sheets/oauth/exchange';
103
104 if ($url === '/google-sheets/oauth/exchange') {
105 return [
106 'success' => false,
107 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'],
108 ];
109 }
110
111 $response = wp_remote_post($url, [
112 'body' => ['exchange' => $exchangeKey],
113 'timeout' => 30,
114 ]);
115
116 if (is_wp_error($response)) {
117 return [
118 'success' => false,
119 'message' => $response->get_error_message(),
120 ];
121 }
122
123 $statusCode = (int) wp_remote_retrieve_response_code($response);
124 $body = json_decode((string) wp_remote_retrieve_body($response), true);
125
126 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body)) {
127 $message = is_array($body) && !empty($body['message'])
128 ? (string) $body['message']
129 : BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'];
130
131 return [
132 'success' => false,
133 'message' => $message,
134 ];
135 }
136
137 $accessToken = (string) ($body['accessToken'] ?? $body['access_token'] ?? '');
138 $refreshToken = (string) ($body['refreshToken'] ?? $body['refresh_token'] ?? '');
139
140 if ($accessToken === '' || $refreshToken === '') {
141 return [
142 'success' => false,
143 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_error'],
144 ];
145 }
146
147 return [
148 'success' => true,
149 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_connection_success'],
150 'accessToken' => $accessToken,
151 'refreshToken' => $refreshToken,
152 'expiresIn' => (int) ($body['expiresIn'] ?? $body['expires_in'] ?? 3600),
153 'email' => sanitize_email((string) ($body['email'] ?? '')),
154 ];
155 }
156
157 /**
158 * Persist tokens received from the OAuth middleware callback.
159 */
160 public function saveOAuthTokens(
161 string $accessToken,
162 string $refreshToken,
163 int $expiresIn,
164 string $email = ''
165 ): void {
166 $settings = $this->getSettings();
167
168 if ($email === '') {
169 $email = $this->fetchUserEmail($accessToken);
170 }
171
172 $settings['authType'] = 'oauth';
173 $settings['accessToken'] = $accessToken;
174 $settings['refreshToken'] = $refreshToken;
175 $settings['tokenExpiresAt'] = time() + max(60, $expiresIn - 30);
176 $settings['accountEmail'] = $email;
177 $settings['connected'] = true;
178 $settings['enabled'] = true;
179
180 $this->saveSettings($settings);
181 }
182
183 public function isConnected(): bool
184 {
185 $settings = $this->getSettings();
186
187 return $this->isOAuthConnection() && !empty($settings['refreshToken']);
188 }
189
190 /**
191 * @return array<string, mixed>
192 */
193 public function getSettings(): array
194 {
195 return $this->settingsService->getSetting('integrations', 'google_sheets') ?? [];
196 }
197
198 /**
199 * @param array<string, mixed> $settings
200 */
201 public function saveSettings(array $settings): void
202 {
203 $this->settingsService->setSetting('integrations', 'google_sheets', $settings);
204 }
205
206 public function disconnect(): void
207 {
208 $settings = $this->getSettings();
209
210 $tokenToRevoke = (string) ($settings['refreshToken'] ?? '');
211 if ($tokenToRevoke === '') {
212 $tokenToRevoke = (string) ($settings['accessToken'] ?? '');
213 }
214
215 if ($tokenToRevoke !== '') {
216 $this->revokeOAuthToken($tokenToRevoke);
217 }
218
219 $settings['accessToken'] = '';
220 $settings['tokenExpiresAt'] = 0;
221 $settings['refreshToken'] = '';
222 $settings['connected'] = false;
223 $settings['accountEmail'] = '';
224 $settings['authType'] = '';
225
226 $this->saveSettings($settings);
227 }
228
229 private function revokeOAuthToken(string $token): void
230 {
231 wp_remote_post('https://oauth2.googleapis.com/revoke', [
232 'headers' => [
233 'Content-Type' => 'application/x-www-form-urlencoded',
234 ],
235 'body' => http_build_query(['token' => $token]),
236 'timeout' => 15,
237 ]);
238 }
239
240 /**
241 * @return array<array{id: string, name: string}>
242 */
243 public function listSpreadsheets(): array
244 {
245 $url = add_query_arg(
246 [
247 'q' => "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false",
248 'fields' => 'files(id,name)',
249 'pageSize' => 100,
250 ],
251 self::DRIVE_FILES_URL
252 );
253
254 $response = $this->apiRequest('GET', $url);
255
256 if (!$response['success']) {
257 return [];
258 }
259
260 $data = json_decode($response['body'], true);
261 $files = $data['files'] ?? [];
262
263 return array_map(static function ($file) {
264 return [
265 'id' => (string) ($file['id'] ?? ''),
266 'name' => (string) ($file['name'] ?? ''),
267 ];
268 }, is_array($files) ? $files : []);
269 }
270
271 /**
272 * @return array<array{title: string, sheet_id: int}>
273 */
274 public function listWorksheets(string $spreadsheetId): array
275 {
276 $url = self::SHEETS_API_URL . '/' . rawurlencode($spreadsheetId) . '?fields=sheets.properties';
277
278 $response = $this->apiRequest('GET', $url);
279
280 if (!$response['success']) {
281 return [];
282 }
283
284 $data = json_decode($response['body'], true);
285 $sheets = $data['sheets'] ?? [];
286 $result = [];
287
288 foreach (is_array($sheets) ? $sheets : [] as $sheet) {
289 $properties = $sheet['properties'] ?? [];
290 if (empty($properties['title'])) {
291 continue;
292 }
293
294 $result[] = [
295 'title' => (string) $properties['title'],
296 'sheet_id' => (int) ($properties['sheetId'] ?? 0),
297 ];
298 }
299
300 return $result;
301 }
302
303 /**
304 * @return array<int, string>
305 */
306 public function listColumns(string $spreadsheetId, string $worksheetName): array
307 {
308 $range = $this->buildRange($worksheetName, '1:1');
309 $url = self::SHEETS_API_URL . '/' . rawurlencode($spreadsheetId) . '/values/' . rawurlencode($range);
310
311 $response = $this->apiRequest('GET', $url);
312
313 if (!$response['success']) {
314 return [];
315 }
316
317 $data = json_decode($response['body'], true);
318 $values = $data['values'][0] ?? [];
319
320 if (!is_array($values)) {
321 return [];
322 }
323
324 return array_values(array_filter(array_map('strval', $values), static function ($value) {
325 return trim($value) !== '';
326 }));
327 }
328
329 /**
330 * @param array<int, string> $rowValues
331 * @return array{success: bool, message: string, status_code: int|null}
332 */
333 public function appendRow(string $spreadsheetId, string $worksheetName, array $rowValues): array
334 {
335 $range = $this->buildRange($worksheetName, 'A:Z');
336 $url = self::SHEETS_API_URL . '/' . rawurlencode($spreadsheetId)
337 . '/values/' . rawurlencode($range) . ':append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS';
338
339 $response = $this->apiRequest('POST', $url, [
340 'values' => [array_values($rowValues)],
341 ]);
342
343 if ($response['success']) {
344 return [
345 'success' => true,
346 'message' => 'Row appended successfully',
347 'status_code' => $response['status_code'],
348 ];
349 }
350
351 return [
352 'success' => false,
353 'message' => $response['message'],
354 'status_code' => $response['status_code'],
355 ];
356 }
357
358 /**
359 * @param array<string, mixed>|null $body
360 * @return array{success: bool, status_code: int|null, body: string, message: string}
361 */
362 private function apiRequest(string $method, string $url, ?array $body = null, ?string $accessToken = null): array
363 {
364 $token = $accessToken ?? $this->getValidAccessToken();
365
366 if ($token === null) {
367 return [
368 'success' => false,
369 'status_code' => null,
370 'body' => '',
371 'message' => 'Google Sheets is not connected',
372 ];
373 }
374
375 $args = [
376 'method' => $method,
377 'headers' => [
378 'Authorization' => 'Bearer ' . $token,
379 'Content-Type' => 'application/json',
380 ],
381 'timeout' => 30,
382 ];
383
384 if ($body !== null) {
385 $args['body'] = wp_json_encode($body);
386 }
387
388 $response = wp_remote_request($url, $args);
389
390 if (is_wp_error($response)) {
391 return [
392 'success' => false,
393 'status_code' => null,
394 'body' => '',
395 'message' => $response->get_error_message(),
396 ];
397 }
398
399 $statusCode = (int) wp_remote_retrieve_response_code($response);
400 $responseBody = (string) wp_remote_retrieve_body($response);
401
402 if ($statusCode === 401 && $accessToken === null) {
403 $refreshed = $this->refreshAccessToken();
404 if ($refreshed) {
405 return $this->apiRequest($method, $url, $body, $this->getValidAccessToken());
406 }
407 }
408
409 $success = $statusCode >= 200 && $statusCode < 300;
410
411 return [
412 'success' => $success,
413 'status_code' => $statusCode,
414 'body' => $responseBody,
415 'message' => $success ? 'OK' : $this->extractErrorMessage($responseBody, $statusCode),
416 ];
417 }
418
419 private function getValidAccessToken(): ?string
420 {
421 $settings = $this->getSettings();
422 $accessToken = (string) ($settings['accessToken'] ?? '');
423 $expiresAt = (int) ($settings['tokenExpiresAt'] ?? 0);
424
425 if ($accessToken === '') {
426 return null;
427 }
428
429 if ($expiresAt > time() + 60) {
430 return $accessToken;
431 }
432
433 if (!$this->refreshAccessToken()) {
434 return null;
435 }
436
437 $settings = $this->getSettings();
438 $accessToken = (string) ($settings['accessToken'] ?? '');
439
440 return $accessToken !== '' ? $accessToken : null;
441 }
442
443 private function refreshAccessToken(): bool
444 {
445 $tokenResult = $this->fetchOAuthAccessToken(true);
446
447 if (!$tokenResult['success']) {
448 return false;
449 }
450
451 $settings = $this->getSettings();
452 $settings['accessToken'] = $tokenResult['access_token'];
453 $settings['tokenExpiresAt'] = $tokenResult['expires_at'];
454 $settings['connected'] = true;
455
456 $this->saveSettings($settings);
457
458 return true;
459 }
460
461 /**
462 * @return array{success: bool, message: string, access_token?: string, expires_at?: int}
463 */
464 private function fetchOAuthAccessToken(bool $forceRefresh): array
465 {
466 $settings = $this->getSettings();
467 $accessToken = (string) ($settings['accessToken'] ?? '');
468 $expiresAt = (int) ($settings['tokenExpiresAt'] ?? 0);
469
470 if (!$forceRefresh && $accessToken !== '' && $expiresAt > time() + 60) {
471 return [
472 'success' => true,
473 'message' => 'OK',
474 'access_token' => $accessToken,
475 'expires_at' => $expiresAt,
476 ];
477 }
478
479 $refreshToken = (string) ($settings['refreshToken'] ?? '');
480
481 if ($refreshToken === '') {
482 return [
483 'success' => false,
484 'message' => BackendStrings::getIntegrationsStrings()['google_sheets_credentials_missing'],
485 ];
486 }
487
488 $response = wp_remote_post($this->getMiddlewareRefreshUrl(), [
489 'headers' => [
490 'Content-Type' => 'application/json',
491 ],
492 'body' => wp_json_encode([
493 'refresh_token' => $refreshToken,
494 'site_url' => IVYFORMS_SITE_URL,
495 ]),
496 'timeout' => 30,
497 ]);
498
499 if (is_wp_error($response)) {
500 return [
501 'success' => false,
502 'message' => $response->get_error_message(),
503 ];
504 }
505
506 $statusCode = (int) wp_remote_retrieve_response_code($response);
507 $body = json_decode((string) wp_remote_retrieve_body($response), true);
508
509 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body) || empty($body['access_token'])) {
510 $message = is_array($body) && !empty($body['error_description'])
511 ? (string) $body['error_description']
512 : (is_array($body) && !empty($body['message'])
513 ? (string) $body['message']
514 : 'Failed to refresh Google access token');
515
516 return [
517 'success' => false,
518 'message' => $message,
519 ];
520 }
521
522 $expiresIn = (int) ($body['expires_in'] ?? 3600);
523
524 return [
525 'success' => true,
526 'message' => 'OK',
527 'access_token' => (string) $body['access_token'],
528 'expires_at' => time() + max(60, $expiresIn - 30),
529 ];
530 }
531
532 private function isOAuthConnection(): bool
533 {
534 $settings = $this->getSettings();
535
536 return !empty($settings['refreshToken'])
537 || (($settings['authType'] ?? '') === 'oauth' && !empty($settings['accessToken']));
538 }
539
540 private function getMiddlewareBaseUrl(): string
541 {
542 if (defined('IVYFORMS_GOOGLE_SHEETS_MIDDLEWARE_URL') && IVYFORMS_GOOGLE_SHEETS_MIDDLEWARE_URL !== '') {
543 return rtrim((string) IVYFORMS_GOOGLE_SHEETS_MIDDLEWARE_URL, '/');
544 }
545
546 $filtered = apply_filters('ivyforms/google_sheets/middleware_url', '');
547
548 return is_string($filtered) ? rtrim($filtered, '/') : '';
549 }
550
551 private function fetchMiddlewareAuthorizationUrl(string $state): string
552 {
553 $baseUrl = $this->getMiddlewareApiBaseUrl();
554
555 if ($baseUrl === '') {
556 return '';
557 }
558
559 $response = wp_remote_get(
560 $baseUrl . '/google-sheets/authorize-url?' . http_build_query(['state' => $state]),
561 ['timeout' => 15]
562 );
563
564 if (is_wp_error($response)) {
565 return '';
566 }
567
568 $statusCode = (int) wp_remote_retrieve_response_code($response);
569 $body = json_decode((string) wp_remote_retrieve_body($response), true);
570
571 if ($statusCode < 200 || $statusCode >= 300 || !is_array($body)) {
572 return '';
573 }
574
575 return (string) ($body['auth_url'] ?? '');
576 }
577
578 private function getMiddlewareRefreshUrl(): string
579 {
580 return $this->getMiddlewareApiBaseUrl() . '/google-sheets/refresh';
581 }
582
583 /**
584 * Base URL for server-side middleware calls (token refresh).
585 * In Docker, localhost points at the container — use host.docker.internal when applicable.
586 */
587 private function getMiddlewareApiBaseUrl(): string
588 {
589 $baseUrl = $this->getMiddlewareBaseUrl();
590
591 if ($baseUrl === '') {
592 return '';
593 }
594
595 if (str_contains($baseUrl, '://localhost') && $this->isRunningInDockerContainer()) {
596 $baseUrl = (string) preg_replace(
597 '#^https?://localhost#i',
598 'http://host.docker.internal',
599 $baseUrl
600 );
601 }
602
603 /**
604 * @param string $baseUrl
605 */
606 return (string) apply_filters('ivyforms/google_sheets/middleware_api_url', $baseUrl);
607 }
608
609 /**
610 * Detect Docker without triggering open_basedir warnings on shared hosting.
611 */
612 private function isRunningInDockerContainer(): bool
613 {
614 $openBasedir = ini_get('open_basedir');
615 if (is_string($openBasedir) && $openBasedir !== '') {
616 $allowed = false;
617 foreach (explode(PATH_SEPARATOR, $openBasedir) as $root) {
618 $root = rtrim($root, '/\\');
619 if ($root !== '' && str_starts_with('/.dockerenv', $root)) {
620 $allowed = true;
621 break;
622 }
623 }
624
625 if (!$allowed) {
626 return false;
627 }
628 }
629
630 return file_exists('/.dockerenv');
631 }
632
633 private function fetchUserEmail(string $accessToken): string
634 {
635 $response = wp_remote_get(self::GOOGLE_USERINFO_URL, [
636 'headers' => [
637 'Authorization' => 'Bearer ' . $accessToken,
638 ],
639 'timeout' => 15,
640 ]);
641
642 if (is_wp_error($response)) {
643 return '';
644 }
645
646 $body = json_decode((string) wp_remote_retrieve_body($response), true);
647
648 if (!is_array($body)) {
649 return '';
650 }
651
652 return sanitize_email((string) ($body['email'] ?? ''));
653 }
654
655 private function extractErrorMessage(string $body, int $statusCode): string
656 {
657 $data = json_decode($body, true);
658
659 if (is_array($data)) {
660 if (!empty($data['error']['message'])) {
661 return (string) $data['error']['message'];
662 }
663
664 if (!empty($data['error_description'])) {
665 return (string) $data['error_description'];
666 }
667 }
668
669 return 'Google API request failed with status ' . $statusCode;
670 }
671
672 private function buildRange(string $worksheetName, string $cellRange): string
673 {
674 $escapedName = str_replace("'", "''", $worksheetName);
675
676 return "'" . $escapedName . "'!" . $cellRange;
677 }
678 }
679