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
← All changes | google-index/class-google-index-admin.php +370 -152 2.6.8 → 2.7.2 View file →
@@ -13,16 +13,24 @@
13 13 if (!defined('ABSPATH')) {
14 14 exit;
15 15 }
16 16
17 -class Google_Index_Admin
17 +class Google_Index_Admin
18 18 {
19 19 /**
20 20 * Section ID for Google Index settings
21 21 */
22 22 private const SECTION_GOOGLE_INDEX = 'google_index_direct_settings';
23 -
23 +
24 24 /**
25 + * Placeholder the redacted display JSON uses in place of the private key.
26 + *
27 + * Its presence means the textarea still holds what the server rendered, so
28 + * the submission carries no new credential and must leave storage alone.
29 + */
30 + private const REDACTED_MARKER = '-----REDACTED-----';
31 +
32 + /**
25 33 * Initialize admin functionality
26 34 */
27 35 public function __construct()
28 36 {
@@ -28,16 +36,16 @@
28 36 {
29 37 // Hook into MetaSync admin initialization
30 38 add_action('admin_init', array($this, 'add_settings_to_metasync'), 20);
31 39 add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts'));
32 -
40 +
33 41 // Hook into MetaSync's AJAX settings processing
34 42 add_action('wp_ajax_meta_sync_save_settings', array($this, 'process_google_index_settings'), 5);
35 43 add_action('wp_ajax_meta_sync_save_seo_controls', array($this, 'process_google_index_settings'), 5);
36 -
44 +
37 45 // Display admin notices after redirect
38 46 add_action('admin_notices', array($this, 'display_admin_notices'));
39 -
47 +
40 48 // AJAX handlers
41 49 add_action('wp_ajax_metasync_google_index_direct_test', array($this, 'ajax_test_connection'));
42 50 }
43 51
@@ -96,9 +104,9 @@
96 104 if (!function_exists('google_index_direct')) {
97 105 if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) {
98 106 require_once plugin_dir_path(__FILE__) . 'google-index-init.php';
99 107 } else {
100 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php');
108 + error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
101 109 return;
102 110 }
103 111 }
104 112
@@ -103,8 +111,12 @@
103 111 }
104 112
105 113 // Get current service account info (safe - doesn't expose private key)
106 114 $google_index = google_index_direct();
115 + if (!$google_index) {
116 + // Nothing to render against, and calling through would be fatal.
117 + return;
118 + }
107 119 $service_info = $google_index->get_service_account_info();
108 120 $is_configured = !isset($service_info['error']);
109 121
110 122 $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
@@ -119,112 +131,212 @@
119 131 */
120 132 public function process_google_index_settings()
121 133 {
122 134 // Only process if our fields are present in the request
123 - if (!isset($_POST['google_index_service_account_json']) &&
124 - !isset($_POST['google_index_clear_config']) &&
125 - !isset($_FILES['google_index_service_account_file'])) {
135 + if (!isset($_POST['google_index_service_account_json']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- early-return checks, a nonce is verified before any write
136 + !isset($_POST['google_index_clear_config']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- early-return checks, a nonce is verified before any write
137 + !isset($_FILES['google_index_service_account_file'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- early-return checks, a nonce is verified before any write
126 138 return; // No Google Index data to process
127 139 }
128 -
140 +
141 + if (!$this->authorize_settings_request()) {
142 + return;
143 + }
144 +
145 + // Clearing does not need to load or inspect credential contents.
146 + if (isset($_POST['google_index_clear_config'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorize_settings_request() verified nonce and capability
147 + $this->clear_stored_service_account();
148 + $this->add_settings_notice('Service account configuration cleared successfully!', 'success');
149 + return;
150 + }
151 +
152 + $result = $this->persist_submitted_service_account();
153 +
154 + if ($result['status'] === 'error') {
155 + // The parent save is still mid-flight; aborting here with an error
156 + // keeps a bad credential from being reported as a successful save.
157 + wp_send_json_error([
158 + 'errors' => ['Google Index: ' . $result['message']],
159 + 'message' => 'Google Index: ' . $result['message'],
160 + ]);
161 + }
162 +
163 + if ($result['status'] === 'unavailable') {
164 + // The sub-module is missing. Let the rest of the settings save
165 + // finish and surface this as a notice instead of failing the page.
166 + $this->add_settings_notice($result['message'], 'warning');
167 + return;
168 + }
169 +
170 + if ($result['status'] === 'saved') {
171 + $this->add_settings_notice('Google Index service account configured successfully!', 'success');
172 + }
173 + }
174 +
175 + /**
176 + * Read the submitted service account from $_POST/$_FILES, validate it, and
177 + * persist it when it is a genuinely new credential.
178 + *
179 + * Returns a status rather than emitting a response because the caller runs
180 + * inside another form's save: it must abort the parent save on a bad
181 + * credential, not answer for the whole request.
182 + *
183 + * @return array{status:string, message:string} status is one of:
184 + * 'saved' - a new credential was written
185 + * 'unchanged' - nothing to write (empty, or the redacted display text)
186 + * 'error' - validation or persistence failed; message explains why
187 + */
188 + private function persist_submitted_service_account()
189 + {
129 190 // Load Google Index functionality
130 - if (!function_exists('google_index_save_service_account')) {
191 + if (!$this->module_function_available('google_index_save_service_account')) {
131 192 if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) {
132 193 require_once plugin_dir_path(__FILE__) . 'google-index-init.php';
133 - } else {
134 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php');
135 - return;
136 194 }
195 +
196 + // A partial deploy can leave the file present but the function
197 + // undefined, so re-check rather than assuming the require worked.
198 + if (!$this->module_function_available('google_index_save_service_account')) {
199 + error_log('MetaSync Google Index: google_index_save_service_account() unavailable; expected google-index-init.php at ' . plugin_dir_path(__FILE__) . 'google-index-init.php'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
200 +
201 + // Deliberately NOT 'error'. This optional sub-module failing to
202 + // load must not abort the parent settings save — the embedded
203 + // caller would turn that into a wp_send_json_error() and take
204 + // the whole General Settings / Indexation Control page down.
205 + return ['status' => 'unavailable', 'message' => 'The Google Index module could not be loaded, so the service account was not saved.'];
206 + }
137 207 }
138 -
208 +
139 209 $service_account_json = '';
140 -
210 +
141 211 // Get JSON from textarea
142 - if (isset($_POST['google_index_service_account_json'])) {
143 - $service_account_json = sanitize_textarea_field(wp_unslash($_POST['google_index_service_account_json']));
212 + if (isset($_POST['google_index_service_account_json'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
213 + $service_account_json = sanitize_textarea_field(wp_unslash($_POST['google_index_service_account_json'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
144 214 }
145 -
215 +
146 216 // Override with file upload if provided
147 - if (isset($_FILES['google_index_service_account_file']) &&
148 - !empty($_FILES['google_index_service_account_file']['tmp_name']) &&
149 - file_exists($_FILES['google_index_service_account_file']['tmp_name'])) {
150 -
151 - $uploaded_json = file_get_contents($_FILES['google_index_service_account_file']['tmp_name']);
217 + if (isset($_FILES['google_index_service_account_file']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
218 + !empty($_FILES['google_index_service_account_file']['tmp_name']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
219 + file_exists($_FILES['google_index_service_account_file']['tmp_name'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
220 +
221 + $uploaded_json = file_get_contents($_FILES['google_index_service_account_file']['tmp_name']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
152 222 if ($uploaded_json !== false) {
153 223 $service_account_json = $uploaded_json; // Removed unnecessary wp_unslash
154 224 }
155 225 }
156 -
157 - // Handle clear configuration
158 - if (isset($_POST['google_index_clear_config'])) {
159 - delete_option('google_index_service_account');
160 - // Success messages shown after redirect (to prevent interfering with MetaSync flow)
161 - $this->add_settings_notice('Service account configuration cleared successfully!', 'success');
226 +
227 + if (empty($service_account_json) || trim($service_account_json) === '') {
228 + return ['status' => 'unchanged', 'message' => ''];
162 229 }
163 - // Process service account JSON if provided
164 - elseif (!empty($service_account_json) && trim($service_account_json) !== '') {
165 -
166 - // Skip if it contains redacted private key (user didn't paste new JSON)
167 - if (strpos($service_account_json, '-----REDACTED-----') !== false) {
168 - return; // Don't process redacted display text
230 +
231 + // Skip if it contains redacted private key (user didn't paste new JSON)
232 + if (strpos($service_account_json, self::REDACTED_MARKER) !== false) {
233 + return ['status' => 'unchanged', 'message' => ''];
234 + }
235 +
236 + $validation = $this->validate_service_account_json($service_account_json);
237 + if (!empty($validation['error'])) {
238 + return ['status' => 'error', 'message' => $validation['error']];
239 + }
240 +
241 + if (!google_index_save_service_account($validation['data'])) {
242 + return [
243 + 'status' => 'error',
244 + 'message' => 'Failed to save service account configuration. Please try again or check your JSON format.',
245 + ];
246 + }
247 +
248 + return ['status' => 'saved', 'message' => 'Google Index service account configured successfully!'];
249 + }
250 +
251 + /**
252 + * Whether a function the optional Google Index sub-module provides exists.
253 + *
254 + * Taking the name as a parameter keeps the check opaque to static analysis,
255 + * which otherwise treats the second call in a load-then-verify pair as dead
256 + * because it cannot see that the intervening require_once may define it.
257 + *
258 + * Genuinely impure: the same argument yields a different answer once the
259 + * sub-module has been required, which is precisely why it is called twice.
260 + *
261 + * @phpstan-impure
262 + *
263 + * @param string $name Function name to look for.
264 + * @return bool
265 + */
266 + private function module_function_available($name)
267 + {
268 + return function_exists($name);
269 + }
270 +
271 + /**
272 + * Decode and structurally validate a service account JSON string.
273 + *
274 + * @param string $json Raw JSON text.
275 + * @return array{data:array|null, error:string} error is '' when valid.
276 + */
277 + private function validate_service_account_json($json)
278 + {
279 + $data = json_decode($json, true);
280 +
281 + if (json_last_error() !== JSON_ERROR_NONE) {
282 + $message = 'Invalid JSON format';
283 + switch (json_last_error()) {
284 + case JSON_ERROR_SYNTAX:
285 + $message .= ' - Syntax error in JSON';
286 + break;
287 + case JSON_ERROR_UTF8:
288 + $message .= ' - Invalid UTF-8 encoding';
289 + break;
290 + default:
291 + $message .= ' - ' . json_last_error_msg();
292 + break;
169 293 }
170 -
171 - // Decode and validate JSON
172 - $service_account_data = json_decode($service_account_json, true);
173 -
174 - if (json_last_error() !== JSON_ERROR_NONE) {
175 - // JSON parsing error - send immediate error response
176 - $json_error_message = 'Invalid JSON format';
177 - switch (json_last_error()) {
178 - case JSON_ERROR_SYNTAX:
179 - $json_error_message .= ' - Syntax error in JSON';
180 - break;
181 - case JSON_ERROR_UTF8:
182 - $json_error_message .= ' - Invalid UTF-8 encoding';
183 - break;
184 - default:
185 - $json_error_message .= ' - ' . json_last_error_msg();
186 - break;
187 - }
188 -
189 - wp_send_json_error([
190 - 'errors' => ['Google Index: ' . $json_error_message . '. Please check your service account JSON.']
191 - ]);
294 +
295 + return ['data' => null, 'error' => $message . '. Please check your service account JSON.'];
296 + }
297 +
298 + if (!is_array($data)) {
299 + return [
300 + 'data' => null,
301 + 'error' => 'JSON must be an object/array. Please check your service account JSON format.',
302 + ];
303 + }
304 +
305 + $required_fields = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri'];
306 + $missing_fields = [];
307 +
308 + foreach ($required_fields as $field) {
309 + if (!isset($data[$field]) || empty($data[$field])) {
310 + $missing_fields[] = $field;
192 311 }
193 -
194 - if (!is_array($service_account_data)) {
195 - wp_send_json_error([
196 - 'errors' => ['Google Index: JSON must be an object/array. Please check your service account JSON format.']
197 - ]);
312 + }
313 +
314 + if (!empty($missing_fields)) {
315 + return [
316 + 'data' => null,
317 + 'error' => 'Missing required fields - ' . implode(', ', $missing_fields) . '. Please ensure you have a complete service account JSON.',
318 + ];
319 + }
320 +
321 + return ['data' => $data, 'error' => ''];
322 + }
323 +
324 + /**
325 + * Remove the stored service account and any token minted from it.
326 + *
327 + * A cached access token that outlives the credential it was issued for
328 + * would keep working until it expired.
329 + */
330 + private function clear_stored_service_account()
331 + {
332 + delete_option('google_index_service_account');
333 +
334 + if (function_exists('google_index_direct')) {
335 + $instance = google_index_direct();
336 + if ($instance) {
337 + $instance->clear_token_cache();
198 338 }
199 -
200 - // Validate required fields
201 - $required_fields = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri'];
202 - $missing_fields = [];
203 -
204 - foreach ($required_fields as $field) {
205 - if (!isset($service_account_data[$field]) || empty($service_account_data[$field])) {
206 - $missing_fields[] = $field;
207 - }
208 - }
209 -
210 - if (!empty($missing_fields)) {
211 - wp_send_json_error([
212 - 'errors' => ['Google Index: Missing required fields - ' . implode(', ', $missing_fields) . '. Please ensure you have a complete service account JSON.']
213 - ]);
214 - }
215 -
216 - // Save using Google Index function
217 - $result = google_index_save_service_account($service_account_data);
218 -
219 - if ($result) {
220 - $this->add_settings_notice('Google Index service account configured successfully!', 'success');
221 - } else {
222 - // Send immediate error response using MetaSync's expected format
223 - wp_send_json_error([
224 - 'errors' => ['Google Index: Failed to save service account configuration. Please try again or check your JSON format.']
225 - ]);
226 - }
227 339 }
228 340 }
229 341
230 342 /**
@@ -252,8 +364,11 @@
252 364 public function display_admin_notices()
253 365 {
254 366 // Only show notices on MetaSync settings pages
255 367 $page_slug = $this->get_metasync_page_slug();
368 + if ( ! function_exists( 'get_current_screen' ) ) {
369 + return;
370 + }
256 371 $current_screen = get_current_screen();
257 372
258 373 if (!$current_screen || strpos($current_screen->id, $page_slug) === false) {
259 374 return;
@@ -262,10 +377,10 @@
262 377 // Get and display notices
263 378 $notices = get_transient('google_index_admin_notices');
264 379 if (!empty($notices)) {
265 380 foreach ($notices as $notice) {
266 - $class = 'notice notice-' . esc_attr($notice['type']) . ' is-dismissible';
267 - echo '<div class="' . $class . '">';
381 + $class = 'notice notice-' . $notice['type'] . ' is-dismissible';
382 + echo '<div class="' . esc_attr($class) . '">';
268 383 echo '<p><strong>Google Index:</strong> ' . esc_html($notice['message']) . '</p>';
269 384 echo '</div>';
270 385 }
271 386
@@ -279,10 +394,10 @@
279 394 */
280 395 public function ajax_test_connection()
281 396 {
282 397 // Check nonce and permissions
283 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'metasync_google_index_direct_test') ||
284 - !Metasync::current_user_has_plugin_access()) {
398 + if (!current_user_can('manage_options') ||
399 + !wp_verify_nonce(wp_unslash($_POST['nonce'] ?? ''), 'metasync_google_index_direct_test')) {
285 400 wp_die('Security check failed');
286 401 }
287 402
288 403 // Load Google Index functionality
@@ -289,9 +404,9 @@
289 404 if (!function_exists('google_index_direct')) {
290 405 if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) {
291 406 require_once plugin_dir_path(__FILE__) . 'google-index-init.php';
292 407 } else {
293 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php');
408 + error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
294 409 return;
295 410 }
296 411 }
297 412
@@ -310,8 +425,40 @@
310 425 'message' => 'Test failed: ' . $e->getMessage()
311 426 ]);
312 427 }
313 428 }
429 +
430 + /**
431 + * Authorize the shared MetaSync settings AJAX action before secret handling.
432 + */
433 + private function authorize_settings_request()
434 + {
435 + if (!current_user_can('manage_options')) {
436 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
437 + }
438 +
439 + $action = isset($_POST['action']) ? sanitize_key(wp_unslash($_POST['action'])) : '';
440 + $nonce_field = '';
441 + $nonce_action = '';
442 +
443 + if ($action === 'meta_sync_save_settings') {
444 + $nonce_field = 'meta_sync_nonce';
445 + $nonce_action = 'meta_sync_general_setting_nonce';
446 + } elseif ($action === 'meta_sync_save_seo_controls') {
447 + $nonce_field = 'meta_sync_seo_controls_nonce';
448 + $nonce_action = 'meta_sync_seo_controls_nonce';
449 + }
450 +
451 + if (
452 + $nonce_field === ''
453 + || !isset($_POST[$nonce_field])
454 + || !wp_verify_nonce(wp_unslash($_POST[$nonce_field]), $nonce_action)
455 + ) {
456 + wp_send_json_error(array('message' => 'Invalid nonce'), 403);
457 + }
458 +
459 + return true;
460 + }
314 461
315 462 /**
316 463 * Enqueue admin scripts and styles
317 464 */
@@ -321,15 +468,14 @@
321 468 $page_slug = $this->get_metasync_page_slug();
322 469 if (strpos($hook, $page_slug) === false) {
323 470 return;
324 471 }
325 -
326 - // Only load on general tab (default tab when none specified)
327 - $current_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
328 - if ($current_tab !== 'general') {
472 +
473 + $current_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page and tab check to enqueue scripts
474 + if ($current_tab !== 'general' && strpos($hook, 'seo-controls') === false) {
329 475 return;
330 476 }
331 -
477 +
332 478 // Add inline JavaScript for functionality
333 479 wp_add_inline_script('jquery', $this->get_admin_javascript());
334 480 }
335 481
@@ -343,61 +489,130 @@
343 489 $nonce = wp_create_nonce('metasync_google_index_direct_test');
344 490
345 491 return "
346 492 jQuery(document).ready(function($) {
493 + // What the textarea held immediately before a file overwrote it.
494 + // Seeded from the server-rendered value and re-snapshotted on each
495 + // file pick, so 'Remove' undoes exactly the file selection — it
496 + // does not also discard a credential the user typed by hand before
497 + // mis-clicking Choose File.
498 + var originalJsonValue = $('#google_index_service_account_json').val();
499 +
500 + // Test Connection builds its results list as an HTML string. The
501 + // values come from the API/server rather than the page, so escape
502 + // them before they reach that sink.
503 + function escHtml(value) {
504 + return $('<div>').text(value === undefined || value === null ? '' : value).html();
505 + }
506 +
507 + function showFileMessage(text, type) {
508 + var box = $('#google-index-file-messages');
509 + if (!box.length) { return; }
510 + box.empty();
511 + if (!text) { return; }
512 + var notice = $('<div>').addClass('notice notice-' + type + ' inline');
513 + notice.append($('<p>').text(text));
514 + box.append(notice);
515 + }
516 +
517 + function clearSelectedFile(restoreTextarea) {
518 + var input = $('#google_index_service_account_file');
519 + if (input.length) {
520 + input.val('');
521 + }
522 + $('#google-index-selected-file').hide();
523 + $('#google-index-selected-file-name').text('');
524 + showFileMessage('', 'info');
525 +
526 + if (restoreTextarea) {
527 + var textarea = $('#google_index_service_account_json');
528 + textarea.val(originalJsonValue);
529 + textarea.trigger('input').trigger('change');
530 + $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change');
531 + }
532 + }
533 +
347 534 // Integration with MetaSync's unsaved changes detection
348 535 function integrateWithUnsavedChangesDetection() {
349 536 // Monitor textarea for changes (avoid recursion)
350 537 $('#google_index_service_account_json').on('input change paste keyup', function(e) {
351 - console.log('Google Index: Textarea changed via', e.type);
352 538 // Trigger MetaSync's change detection (works on both Settings and Indexation Control pages)
353 539 $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change');
354 540 });
355 -
541 +
356 542 // Handle file upload with auto-populate and change detection
357 543 $('#google_index_service_account_file').on('change', function(e) {
358 544 var file = e.target.files[0];
359 545 var textarea = $('#google_index_service_account_json');
360 -
361 - if (file && file.type === 'application/json') {
362 - var reader = new FileReader();
363 - reader.onload = function(e) {
364 - try {
365 - var json = JSON.parse(e.target.result);
366 - // Update textarea value
367 - var jsonString = JSON.stringify(json, null, 2);
368 - textarea.val(jsonString);
369 -
370 - // Create and dispatch native events to ensure proper detection
371 - setTimeout(function() {
372 - // Create native events
373 - var inputEvent = new Event('input', { bubbles: true, cancelable: true });
374 - var changeEvent = new Event('change', { bubbles: true, cancelable: true });
375 -
376 - // Dispatch events on the actual DOM element (not jQuery)
377 - textarea[0].dispatchEvent(inputEvent);
378 - textarea[0].dispatchEvent(changeEvent);
379 -
380 - // Also trigger jQuery events as backup
381 - textarea.trigger('input').trigger('change');
382 -
383 - console.log('Google Index: File upload triggered change detection');
384 - }, 100);
385 - } catch (err) {
386 - alert('Invalid JSON file selected.');
387 - }
388 - };
389 - reader.readAsText(file);
390 - } else {
391 - // File upload indicates change even if not JSON (works on both Settings and Indexation Control pages)
392 - $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change');
546 +
547 + if (!file) {
548 + clearSelectedFile(true);
549 + return;
393 550 }
551 +
552 + if (!file.name.toLowerCase().endsWith('.json')) {
553 + // Inline feedback instead of the old blocking alert().
554 + showFileMessage('\"' + file.name + '\" is not a .json file. Choose the service account key file you downloaded from Google Cloud.', 'error');
555 + $('#google-index-selected-file').hide();
556 + $('#google_index_service_account_file').val('');
557 + return;
558 + }
559 +
560 + // Snapshot only when no file is currently selected, i.e. at
561 + // the START of a pick sequence. Re-snapshotting on every
562 + // pick would make Remove restore file A's contents after
563 + // the user picked A then B then removed — leaving a
564 + // credential in the box while the UI says no file is
565 + // selected.
566 + if (!$('#google-index-selected-file').is(':visible')) {
567 + originalJsonValue = textarea.val();
568 + }
569 +
570 + var reader = new FileReader();
571 + reader.onload = function(ev) {
572 + try {
573 + var json = JSON.parse(ev.target.result);
574 + var jsonString = JSON.stringify(json, null, 2);
575 + textarea.val(jsonString);
576 +
577 + $('#google-index-selected-file-name').text(file.name);
578 + $('#google-index-selected-file').show();
579 + showFileMessage('\"' + file.name + '\" loaded. Save the page to store it.', 'info');
580 +
581 + // Create and dispatch native events to ensure proper detection
582 + setTimeout(function() {
583 + var inputEvent = new Event('input', { bubbles: true, cancelable: true });
584 + var changeEvent = new Event('change', { bubbles: true, cancelable: true });
585 +
586 + textarea[0].dispatchEvent(inputEvent);
587 + textarea[0].dispatchEvent(changeEvent);
588 +
589 + textarea.trigger('input').trigger('change');
590 + }, 100);
591 + } catch (err) {
592 + showFileMessage('\"' + file.name + '\" is not valid JSON. Re-download the key file from Google Cloud and try again.', 'error');
593 + $('#google-index-selected-file').hide();
594 + $('#google_index_service_account_file').val('');
595 + }
596 + };
597 + reader.onerror = function() {
598 + showFileMessage('\"' + file.name + '\" could not be read. Try choosing it again.', 'error');
599 + $('#google-index-selected-file').hide();
600 + $('#google_index_service_account_file').val('');
601 + };
602 + reader.readAsText(file);
394 603 });
604 +
605 + // Remove/reselect
606 + $('#google-index-remove-file').on('click', function(e) {
607 + e.preventDefault();
608 + clearSelectedFile(true);
609 + });
395 610 }
396 -
611 +
397 612 // Initialize integration after a small delay to ensure MetaSync is ready
398 613 setTimeout(integrateWithUnsavedChangesDetection, 100);
399 -
614 +
400 615 // Handle test connection button
401 616 $('#google-index-test-connection').on('click', function(e) {
402 617 e.preventDefault();
403 618
@@ -426,10 +641,10 @@
426 641 html += '<li><strong>Token Cached:</strong> ' + (results.token_test.cached ? '✅ Yes' : '⚪ No') + '</li>';
427 642 }
428 643
429 644 if (results.credentials_test) {
430 - html += '<li><strong>Service Account:</strong> ' + results.credentials_test.client_email + '</li>';
431 - html += '<li><strong>Project ID:</strong> ' + results.credentials_test.project_id + '</li>';
645 + html += '<li><strong>Service Account:</strong> ' + escHtml(results.credentials_test.client_email) + '</li>';
646 + html += '<li><strong>Project ID:</strong> ' + escHtml(results.credentials_test.project_id) + '</li>';
432 647 html += '<li><strong>Private Key:</strong> ' + (results.credentials_test.has_private_key ? '✅ Present' : '❌ Missing') + '</li>';
433 648 }
434 649
435 650 if (results.homepage_test) {
@@ -435,11 +650,11 @@
435 650 if (results.homepage_test) {
436 651 if (results.homepage_test.success) {
437 652 html += '<li><strong>Homepage Status:</strong> ✅ Success</li>';
438 653 } else {
439 - html += '<li><strong>Homepage Status:</strong> ⚠️ ' + results.homepage_test.error.message + '</li>';
654 + html += '<li><strong>Homepage Status:</strong> ⚠️ ' + escHtml(results.homepage_test.error.message) + '</li>';
440 655 if (results.homepage_test.note) {
441 - html += '<li><strong>Note:</strong> ' + results.homepage_test.note + '</li>';
656 + html += '<li><strong>Note:</strong> ' + escHtml(results.homepage_test.note) + '</li>';
442 657 }
443 658 }
444 659 }
445 660
@@ -445,9 +660,9 @@
445 660
446 661 html += '</ul></div>';
447 662 resultDiv.html(html);
448 663 } else {
449 - resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Test Failed:</strong> ' + response.data.message + '</p></div>');
664 + resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Test Failed:</strong> ' + escHtml(response.data.message) + '</p></div>');
450 665 }
451 666 },
452 667 error: function() {
453 668 resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Connection Error:</strong> Unable to perform test.</p></div>');
@@ -460,24 +675,27 @@
460 675
461 676 // Handle clear configuration button
462 677 $('#google-index-clear-config').on('click', function(e) {
463 678 e.preventDefault();
464 -
679 +
465 680 if (!confirm('Are you sure you want to clear the service account configuration?')) {
466 681 return;
467 682 }
468 -
469 - // Create hidden input to indicate clear configuration request
683 +
684 + // Embedded contexts: piggyback on the page's settings form.
470 685 var input = $('<input>')
471 686 .attr('type', 'hidden')
472 687 .attr('name', 'google_index_clear_config')
473 688 .attr('value', '1');
474 -
475 - // Append to form
476 - $('#metaSyncGeneralSetting').append(input);
477 -
478 - // Trigger the form's existing AJAX submission handler
479 - $('#metaSyncGeneralSetting').trigger('submit');
689 +
690 + // Append to the correct form (Indexation Control vs General Settings) and submit via AJAX
691 + var clrForm = $('#metaSyncSeoControlsForm').length ? $('#metaSyncSeoControlsForm') : $('#metaSyncGeneralSetting');
692 + var clrAction = $('#metaSyncSeoControlsForm').length ? 'meta_sync_save_seo_controls' : 'meta_sync_save_settings';
693 + clrForm.append(input);
694 + var clrData = clrForm.serialize() + '&action=' + clrAction;
695 +
696 + // Submit the clear request directly via AJAX, then reload
697 + $.ajax({ url: ajaxurl || metaSync.ajax_url, type: 'POST', data: clrData, complete: function() { window.location.reload(); } });
480 698 });
481 699
482 700 });
483 701 ";