PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.7.2, at google-index/class-google-index-direct.php

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