PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / google-index / class-google-index-direct.php

class-google-index-direct.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.10, at google-index/class-google-index-direct.php

659 lines 21.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Direct Google Indexing API Implementation
5 *
6 * Lightweight implementation without external libraries
7 * Uses WordPress options for secure credential management
8 *
9 * @package GoogleIndexDirect
10 * @version 1.0.0
11 */
12
13 // Prevent direct access
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 class Google_Index_Direct
19 {
20 /**
21 * WordPress option key for storing service account credentials
22 */
23 private const SERVICE_ACCOUNT_OPTION_KEY = 'google_index_service_account';
24
25 /**
26 * Google Indexing API endpoints
27 */
28 private const API_BASE_URL = 'https://indexing.googleapis.com/v3';
29 private const PUBLISH_URL = self::API_BASE_URL . '/urlNotifications:publish';
30 private const STATUS_URL = self::API_BASE_URL . '/urlNotifications/metadata';
31 private const TOKEN_URL = 'https://oauth2.googleapis.com/token';
32
33 /**
34 * OAuth scope for Google Indexing API
35 */
36 private const SCOPE = 'https://www.googleapis.com/auth/indexing';
37
38 /**
39 * Cache key for access token
40 */
41 private const TOKEN_CACHE_KEY = 'google_index_access_token';
42
43 /**
44 * Get service account configuration from WordPress options
45 *
46 * @return array|false Service account configuration or false if not found
47 */
48 private function get_service_account_config()
49 {
50 $config = get_option(self::SERVICE_ACCOUNT_OPTION_KEY);
51
52 // Fallback: check legacy instant-index option if new option is empty
53 if (empty($config)) {
54 $legacy = get_option('metasync_options_instant_indexing');
55 if (!empty($legacy['json_key'])) {
56 $config = json_decode($legacy['json_key'], true);
57 }
58 }
59
60 if (empty($config)) {
61 error_log('MetaSync Google Index: Service account configuration not found in options table. Option key: ' . self::SERVICE_ACCOUNT_OPTION_KEY);
62 return false;
63 }
64
65 // Decode JSON if stored as string
66 if (is_string($config)) {
67 $decoded = json_decode($config, true);
68 if (json_last_error() === JSON_ERROR_NONE) {
69 $config = $decoded;
70 }
71 }
72
73 // Validate required fields
74 $required_fields = ['client_email', 'private_key', 'project_id'];
75 foreach ($required_fields as $field) {
76 if (empty($config[$field])) {
77 error_log("MetaSync Google Index: Missing required field '{$field}' in service account configuration");
78 return false;
79 }
80 }
81
82 return $config;
83 }
84
85 /**
86 * Get credentials test data for test_connection method
87 *
88 * @return array Credentials test data
89 */
90 private function get_credentials_test_data()
91 {
92 $service_account = $this->get_service_account_config();
93 if (!$service_account) {
94 return [
95 'error' => 'Service account configuration not found',
96 'client_email' => 'Not configured',
97 'project_id' => 'Not configured',
98 'has_private_key' => false
99 ];
100 }
101
102 return [
103 'client_email' => $service_account['client_email'] ?? 'Not set',
104 'project_id' => $service_account['project_id'] ?? 'Not set',
105 'has_private_key' => !empty($service_account['private_key'])
106 ];
107 }
108
109 /**
110 * Save service account configuration to WordPress options
111 *
112 * @param array $config Service account configuration array
113 * @return bool True on success, false on failure
114 */
115 public function save_service_account_config($config)
116 {
117 // Validate required fields
118 $required_fields = ['client_email', 'private_key', 'project_id'];
119 foreach ($required_fields as $field) {
120 if (empty($config[$field])) {
121 error_log("MetaSync Google Index: Cannot save - missing required field '{$field}' in service account configuration");
122 return false;
123 }
124 }
125
126 // Clear any cached tokens when updating credentials
127 $this->clear_token_cache();
128
129 // Check if configuration already exists and is identical
130 $existing_config = get_option(self::SERVICE_ACCOUNT_OPTION_KEY);
131 if ($existing_config && $existing_config === $config) {
132 return true; // Consider identical config as success
133 }
134
135 // Save to options table
136 $result = update_option(self::SERVICE_ACCOUNT_OPTION_KEY, $config);
137
138 if (!$result) {
139 // update_option returns false if the value is the same OR if there was an error
140 // Let's check if the option was actually saved correctly
141 $saved_config = get_option(self::SERVICE_ACCOUNT_OPTION_KEY);
142 if ($saved_config && $saved_config === $config) {
143 return true;
144 } else {
145 // NEW: Structured error logging with category and code
146 global $wpdb;
147 if (class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
148 Metasync_Error_Logger::log(
149 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
150 Metasync_Error_Logger::SEVERITY_ERROR,
151 'Failed to save Google service account configuration to database',
152 [
153 'option_key' => self::SERVICE_ACCOUNT_OPTION_KEY,
154 'wpdb_error' => $wpdb->last_error,
155 'wpdb_last_query' => $wpdb->last_query,
156 'operation' => 'save_service_account_config',
157 'project_id' => $config['project_id'] ?? 'unknown'
158 ]
159 );
160 }
161
162 error_log('MetaSync Google Index: Failed to save service account configuration - database error or data too large');
163 return false;
164 }
165 }
166
167 return $result;
168 }
169
170 /**
171 * Index a WordPress post by ID and type
172 *
173 * @param int $post_id WordPress post ID
174 * @param string $post_type WordPress post type (post, page, etc.)
175 * @param string $action Action to perform: 'update', 'delete', or 'status'
176 * @return array API response
177 */
178 public function index_post($post_id, $post_type = 'post', $action = 'update')
179 {
180 // Validate inputs
181 if (!is_numeric($post_id) || $post_id <= 0) {
182 return $this->error_response('Invalid post ID provided');
183 }
184
185 // Get post URL
186 $post_url = get_permalink($post_id);
187 if (!$post_url || is_wp_error($post_url)) {
188 return $this->error_response('Unable to get permalink for post ID: ' . $post_id);
189 }
190
191 // Get post object for validation
192 $post = get_post($post_id);
193 if (!$post || $post->post_type !== $post_type) {
194 return $this->error_response('Post not found or post type mismatch');
195 }
196
197 // Only index published posts (unless deleting)
198 if ($action !== 'delete' && $post->post_status !== 'publish') {
199 return $this->error_response('Only published posts can be indexed');
200 }
201
202 // Log the action
203 error_log(sprintf(
204 'Google Index Direct: %s action for %s (ID: %d, Type: %s, URL: %s)',
205 strtoupper($action),
206 $post->post_title,
207 $post_id,
208 $post_type,
209 $post_url
210 ));
211
212 // Perform the API call
213 return $this->call_indexing_api($post_url, $action);
214 }
215
216 /**
217 * Index multiple posts by their IDs
218 *
219 * @param array $post_ids Array of post IDs
220 * @param string $post_type Post type to filter by
221 * @param string $action Action to perform
222 * @return array Results for each post
223 */
224 public function index_multiple_posts($post_ids, $post_type = 'post', $action = 'update')
225 {
226 $results = [];
227
228 foreach ($post_ids as $post_id) {
229 $results[$post_id] = $this->index_post($post_id, $post_type, $action);
230
231 // Add small delay between requests to avoid rate limiting
232 usleep(500000); // 0.5 second delay
233 }
234
235 return $results;
236 }
237
238 /**
239 * Index a URL directly
240 *
241 * @param string $url URL to index
242 * @param string $action Action to perform
243 * @return array API response
244 */
245 public function index_url($url, $action = 'update')
246 {
247 if (!filter_var($url, FILTER_VALIDATE_URL)) {
248 return $this->error_response('Invalid URL provided: ' . $url);
249 }
250
251 return $this->call_indexing_api($url, $action);
252 }
253
254 /**
255 * Get indexing status for a URL
256 *
257 * @param string $url URL to check status for
258 * @return array API response
259 */
260 public function get_url_status($url)
261 {
262 return $this->index_url($url, 'status');
263 }
264
265 /**
266 * Call Google Indexing API directly
267 *
268 * @param string $url URL to process
269 * @param string $action Action: update, delete, or status
270 * @return array API response
271 */
272 private function call_indexing_api($url, $action)
273 {
274 // Get access token
275 $access_token = $this->get_access_token();
276 if (!$access_token) {
277 return $this->error_response('Failed to obtain Google access token');
278 }
279
280 // Prepare request based on action
281 switch ($action) {
282 case 'status':
283 return $this->get_url_metadata($url, $access_token);
284
285 case 'update':
286 return $this->publish_url_notification($url, 'URL_UPDATED', $access_token);
287
288 case 'delete':
289 return $this->publish_url_notification($url, 'URL_DELETED', $access_token);
290
291 default:
292 return $this->error_response('Invalid action: ' . $action);
293 }
294 }
295
296 /**
297 * Get URL metadata (status check)
298 *
299 * @param string $url URL to check
300 * @param string $access_token Google access token
301 * @return array API response
302 */
303 private function get_url_metadata($url, $access_token)
304 {
305 $request_url = self::STATUS_URL . '?' . http_build_query(['url' => $url]);
306
307 $response = wp_remote_get($request_url, [
308 'headers' => [
309 'Authorization' => 'Bearer ' . $access_token,
310 'Content-Type' => 'application/json',
311 'User-Agent' => 'WordPress-GoogleIndex-Direct/1.0'
312 ],
313 'timeout' => 30,
314 'sslverify' => true
315 ]);
316
317 return $this->handle_api_response($response, 'GET_STATUS');
318 }
319
320 /**
321 * Publish URL notification (update or delete)
322 *
323 * @param string $url URL to process
324 * @param string $type Notification type: URL_UPDATED or URL_DELETED
325 * @param string $access_token Google access token
326 * @return array API response
327 */
328 private function publish_url_notification($url, $type, $access_token)
329 {
330 $payload = [
331 'url' => $url,
332 'type' => $type
333 ];
334
335 $response = wp_remote_post(self::PUBLISH_URL, [
336 'headers' => [
337 'Authorization' => 'Bearer ' . $access_token,
338 'Content-Type' => 'application/json',
339 'User-Agent' => 'WordPress-GoogleIndex-Direct/1.0'
340 ],
341 'body' => json_encode($payload),
342 'timeout' => 30,
343 'sslverify' => true
344 ]);
345
346 return $this->handle_api_response($response, $type);
347 }
348
349 /**
350 * Handle API response
351 *
352 * @param WP_Error|array $response WordPress HTTP response
353 * @param string $action Action being performed
354 * @return array Processed response
355 */
356 private function handle_api_response($response, $action)
357 {
358 // Check for HTTP errors
359 if (is_wp_error($response)) {
360 // NEW: Structured error logging with category and code
361 if (class_exists('Metasync_Error_Logger')) {
362 Metasync_Error_Logger::log(
363 Metasync_Error_Logger::CATEGORY_NETWORK_ERROR,
364 Metasync_Error_Logger::SEVERITY_ERROR,
365 'Google Index API network request failed',
366 [
367 'action' => $action,
368 'error_message' => $response->get_error_message(),
369 'error_code' => $response->get_error_code(),
370 'api_endpoint' => 'Google Indexing API',
371 'operation' => 'handle_api_response'
372 ]
373 );
374 }
375
376 return $this->error_response(
377 'HTTP request failed: ' . $response->get_error_message(),
378 'HTTP_ERROR'
379 );
380 }
381
382 $status_code = wp_remote_retrieve_response_code($response);
383 $body = wp_remote_retrieve_body($response);
384
385 // Log the response for debugging
386 error_log(sprintf(
387 'Google Index API Response - Action: %s, Status: %d, Body: %s',
388 $action,
389 $status_code,
390 $body
391 ));
392
393 // Parse response body
394 $data = json_decode($body, true);
395 if (json_last_error() !== JSON_ERROR_NONE) {
396 return $this->error_response(
397 'Invalid JSON response from Google API',
398 'INVALID_JSON',
399 ['raw_response' => $body, 'status_code' => $status_code]
400 );
401 }
402
403 // Handle different HTTP status codes
404 if ($status_code >= 200 && $status_code < 300) {
405 return $this->success_response($data, $action);
406 } elseif ($status_code >= 400 && $status_code < 500) {
407 $error_message = $data['error']['message'] ?? 'Client error occurred';
408 return $this->error_response(
409 $error_message,
410 'CLIENT_ERROR',
411 ['status_code' => $status_code, 'google_error' => $data]
412 );
413 } else {
414 return $this->error_response(
415 'Server error from Google API',
416 'SERVER_ERROR',
417 ['status_code' => $status_code, 'response' => $data]
418 );
419 }
420 }
421
422 /**
423 * Get Google OAuth access token
424 *
425 * @return string|false Access token or false on failure
426 */
427 private function get_access_token()
428 {
429 // Check cache first
430 $cached_token = get_transient(self::TOKEN_CACHE_KEY);
431 if ($cached_token) {
432 return $cached_token;
433 }
434
435 // Generate new token
436 $jwt_token = $this->create_jwt_token();
437 if (!$jwt_token) {
438 return false;
439 }
440
441 // Exchange JWT for access token
442 $response = wp_remote_post(self::TOKEN_URL, [
443 'headers' => [
444 'Content-Type' => 'application/x-www-form-urlencoded'
445 ],
446 'body' => http_build_query([
447 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
448 'assertion' => $jwt_token
449 ]),
450 'timeout' => 30
451 ]);
452
453 if (is_wp_error($response)) {
454 // NEW: Structured error logging with category and code
455 if (class_exists('Metasync_Error_Logger')) {
456 Metasync_Error_Logger::log(
457 Metasync_Error_Logger::CATEGORY_NETWORK_ERROR,
458 Metasync_Error_Logger::SEVERITY_ERROR,
459 'Google OAuth token request network error',
460 [
461 'error_message' => $response->get_error_message(),
462 'error_code' => $response->get_error_code(),
463 'api_endpoint' => 'Google OAuth Token API',
464 'operation' => 'get_access_token'
465 ]
466 );
467 }
468
469 error_log('MetaSync Google Index: Failed to get access token - ' . $response->get_error_message());
470 return false;
471 }
472
473 $body = json_decode(wp_remote_retrieve_body($response), true);
474 if (!isset($body['access_token'])) {
475 error_log('MetaSync Google Index: No access token in response - ' . print_r($body, true));
476 return false;
477 }
478
479 $access_token = $body['access_token'];
480 $expires_in = $body['expires_in'] ?? 3600;
481
482 // Cache the token (expire 5 minutes before actual expiry)
483 set_transient(self::TOKEN_CACHE_KEY, $access_token, $expires_in - 300);
484
485 return $access_token;
486 }
487
488 /**
489 * Create JWT token for Google API authentication
490 *
491 * @return string|false JWT token or false on failure
492 */
493 private function create_jwt_token()
494 {
495 // Get service account configuration
496 $service_account = $this->get_service_account_config();
497 if (!$service_account) {
498 return false;
499 }
500
501 $header = json_encode([
502 'alg' => 'RS256',
503 'typ' => 'JWT'
504 ]);
505
506 $now = time();
507 $payload = json_encode([
508 'iss' => $service_account['client_email'],
509 'scope' => self::SCOPE,
510 'aud' => self::TOKEN_URL,
511 'iat' => $now,
512 'exp' => $now + 3600 // 1 hour
513 ]);
514
515 $base64_header = $this->base64url_encode($header);
516 $base64_payload = $this->base64url_encode($payload);
517
518 $signature_input = $base64_header . '.' . $base64_payload;
519
520 // Sign with private key
521 $signature = '';
522 $success = openssl_sign(
523 $signature_input,
524 $signature,
525 $service_account['private_key'],
526 'SHA256'
527 );
528
529 if (!$success) {
530 error_log('MetaSync Google Index: Failed to sign JWT token');
531 return false;
532 }
533
534 $base64_signature = $this->base64url_encode($signature);
535
536 return $signature_input . '.' . $base64_signature;
537 }
538
539 /**
540 * Base64 URL encode
541 *
542 * @param string $data Data to encode
543 * @return string Encoded data
544 */
545 private function base64url_encode($data)
546 {
547 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
548 }
549
550 /**
551 * Create success response
552 *
553 * @param array $data Response data
554 * @param string $action Action performed
555 * @return array Success response
556 */
557 private function success_response($data, $action)
558 {
559 return [
560 'success' => true,
561 'action' => $action,
562 'timestamp' => current_time('mysql'),
563 'data' => $data
564 ];
565 }
566
567 /**
568 * Create error response
569 *
570 * @param string $message Error message
571 * @param string $code Error code
572 * @param array $details Additional error details
573 * @return array Error response
574 */
575 private function error_response($message, $code = 'UNKNOWN_ERROR', $details = [])
576 {
577 error_log('MetaSync Google Index Error: ' . $message . ' (Code: ' . $code . ')');
578
579 return [
580 'success' => false,
581 'error' => [
582 'message' => $message,
583 'code' => $code,
584 'details' => $details
585 ],
586 'timestamp' => current_time('mysql')
587 ];
588 }
589
590 /**
591 * Test the API connection
592 *
593 * @return array Test results
594 */
595 public function test_connection()
596 {
597 // Test with site homepage
598 $homepage_url = get_home_url();
599
600 return [
601 'homepage_test' => $this->get_url_status($homepage_url),
602 'token_test' => [
603 'success' => !empty($this->get_access_token()),
604 'cached' => !empty(get_transient(self::TOKEN_CACHE_KEY))
605 ],
606 'credentials_test' => $this->get_credentials_test_data()
607 ];
608 }
609
610 /**
611 * Clear cached access token
612 */
613 public function clear_token_cache()
614 {
615 delete_transient(self::TOKEN_CACHE_KEY);
616 }
617
618 /**
619 * Get saved service account JSON for display with private key redacted.
620 *
621 * @return string Pretty-printed JSON string or empty string if not configured
622 */
623 public function get_redacted_config_json()
624 {
625 $config = get_option(self::SERVICE_ACCOUNT_OPTION_KEY);
626 if (empty($config) || !is_array($config)) {
627 return '';
628 }
629 $config['private_key'] = '-----REDACTED-----';
630 return json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
631 }
632
633 /**
634 * Get service account info (for debugging)
635 *
636 * @return array Service account details
637 */
638 public function get_service_account_info()
639 {
640 $service_account = $this->get_service_account_config();
641 if (!$service_account) {
642 return [
643 'error' => 'Service account configuration not found',
644 'client_email' => 'Not configured',
645 'project_id' => 'Not configured',
646 'private_key_id' => 'Not configured',
647 'has_private_key' => false
648 ];
649 }
650
651 return [
652 'client_email' => $service_account['client_email'] ?? 'Not set',
653 'project_id' => $service_account['project_id'] ?? 'Not set',
654 'private_key_id' => $service_account['private_key_id'] ?? 'Not set',
655 'has_private_key' => !empty($service_account['private_key'])
656 ];
657 }
658 }
659