PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +570 -151 2.6.10 → 2.7.1 View file →
@@ -13,16 +13,29 @@
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 + * Nonce action shared by the two dedicated (standalone-page) endpoints.
26 + */
27 + private const ACCOUNT_NONCE_ACTION = 'metasync_google_index_account';
28 +
29 + /**
30 + * Placeholder the redacted display JSON uses in place of the private key.
31 + *
32 + * Its presence means the textarea still holds what the server rendered, so
33 + * the submission carries no new credential and must leave storage alone.
34 + */
35 + private const REDACTED_MARKER = '-----REDACTED-----';
36 +
37 + /**
25 38 * Initialize admin functionality
26 39 */
27 40 public function __construct()
28 41 {
@@ -28,18 +41,23 @@
28 41 {
29 42 // Hook into MetaSync admin initialization
30 43 add_action('admin_init', array($this, 'add_settings_to_metasync'), 20);
31 44 add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts'));
32 -
45 +
33 46 // Hook into MetaSync's AJAX settings processing
34 47 add_action('wp_ajax_meta_sync_save_settings', array($this, 'process_google_index_settings'), 5);
35 48 add_action('wp_ajax_meta_sync_save_seo_controls', array($this, 'process_google_index_settings'), 5);
36 -
49 +
37 50 // Display admin notices after redirect
38 51 add_action('admin_notices', array($this, 'display_admin_notices'));
39 -
52 +
40 53 // AJAX handlers
41 54 add_action('wp_ajax_metasync_google_index_direct_test', array($this, 'ajax_test_connection'));
55 +
56 + // Dedicated save/clear for the standalone Instant Indexing page, which
57 + // has no settings form to piggyback on.
58 + add_action('wp_ajax_metasync_google_index_save_account', array($this, 'ajax_save_service_account'));
59 + add_action('wp_ajax_metasync_google_index_clear_account', array($this, 'ajax_clear_service_account'));
42 60 }
43 61
44 62
45 63 /**
@@ -96,9 +114,9 @@
96 114 if (!function_exists('google_index_direct')) {
97 115 if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) {
98 116 require_once plugin_dir_path(__FILE__) . 'google-index-init.php';
99 117 } else {
100 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php');
118 + 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 119 return;
102 120 }
103 121 }
104 122
@@ -103,8 +121,12 @@
103 121 }
104 122
105 123 // Get current service account info (safe - doesn't expose private key)
106 124 $google_index = google_index_direct();
125 + if (!$google_index) {
126 + // Nothing to render against, and calling through would be fatal.
127 + return;
128 + }
107 129 $service_info = $google_index->get_service_account_info();
108 130 $is_configured = !isset($service_info['error']);
109 131
110 132 $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
@@ -119,114 +141,294 @@
119 141 */
120 142 public function process_google_index_settings()
121 143 {
122 144 // 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'])) {
145 + if (!isset($_POST['google_index_service_account_json']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- early-return checks, a nonce is verified before any write
146 + !isset($_POST['google_index_clear_config']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- early-return checks, a nonce is verified before any write
147 + !isset($_FILES['google_index_service_account_file'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- early-return checks, a nonce is verified before any write
126 148 return; // No Google Index data to process
127 149 }
128 -
150 +
151 + if (!$this->authorize_settings_request()) {
152 + return;
153 + }
154 +
155 + // Clearing does not need to load or inspect credential contents.
156 + if (isset($_POST['google_index_clear_config'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorize_settings_request() verified nonce and capability
157 + $this->clear_stored_service_account();
158 + $this->add_settings_notice('Service account configuration cleared successfully!', 'success');
159 + return;
160 + }
161 +
162 + $result = $this->persist_submitted_service_account();
163 +
164 + if ($result['status'] === 'error') {
165 + // The parent save is still mid-flight; aborting here with an error
166 + // keeps a bad credential from being reported as a successful save.
167 + wp_send_json_error([
168 + 'errors' => ['Google Index: ' . $result['message']],
169 + 'message' => 'Google Index: ' . $result['message'],
170 + ]);
171 + }
172 +
173 + if ($result['status'] === 'unavailable') {
174 + // The sub-module is missing. Let the rest of the settings save
175 + // finish and surface this as a notice instead of failing the page.
176 + $this->add_settings_notice($result['message'], 'warning');
177 + return;
178 + }
179 +
180 + if ($result['status'] === 'saved') {
181 + $this->add_settings_notice('Google Index service account configured successfully!', 'success');
182 + }
183 + }
184 +
185 + /**
186 + * Read the submitted service account from $_POST/$_FILES, validate it, and
187 + * persist it when it is a genuinely new credential.
188 + *
189 + * Shared by the form-embedded flow and the dedicated standalone endpoint so
190 + * both accept and reject exactly the same payloads. Returns a status rather
191 + * than emitting a response, because the two callers report differently: the
192 + * embedded flow must abort the parent save, the dedicated endpoint owns the
193 + * whole response.
194 + *
195 + * @return array{status:string, message:string} status is one of:
196 + * 'saved' - a new credential was written
197 + * 'unchanged' - nothing to write (empty, or the redacted display text)
198 + * 'error' - validation or persistence failed; message explains why
199 + */
200 + private function persist_submitted_service_account()
201 + {
129 202 // Load Google Index functionality
130 - if (!function_exists('google_index_save_service_account')) {
203 + if (!$this->module_function_available('google_index_save_service_account')) {
131 204 if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) {
132 205 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 206 }
207 +
208 + // A partial deploy can leave the file present but the function
209 + // undefined, so re-check rather than assuming the require worked.
210 + if (!$this->module_function_available('google_index_save_service_account')) {
211 + 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
212 +
213 + // Deliberately NOT 'error'. This optional sub-module failing to
214 + // load must not abort the parent settings save — the embedded
215 + // caller would turn that into a wp_send_json_error() and take
216 + // the whole General Settings / Indexation Control page down.
217 + return ['status' => 'unavailable', 'message' => 'The Google Index module could not be loaded, so the service account was not saved.'];
218 + }
137 219 }
138 -
220 +
139 221 $service_account_json = '';
140 -
222 +
141 223 // 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']));
224 + if (isset($_POST['google_index_service_account_json'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
225 + $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 226 }
145 -
227 +
146 228 // 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']);
229 + if (isset($_FILES['google_index_service_account_file']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
230 + !empty($_FILES['google_index_service_account_file']['tmp_name']) && // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
231 + file_exists($_FILES['google_index_service_account_file']['tmp_name'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- private, callers verify a nonce and capability first
232 +
233 + $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 234 if ($uploaded_json !== false) {
153 235 $service_account_json = $uploaded_json; // Removed unnecessary wp_unslash
154 236 }
155 237 }
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');
238 +
239 + if (empty($service_account_json) || trim($service_account_json) === '') {
240 + return ['status' => 'unchanged', 'message' => ''];
162 241 }
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
242 +
243 + // Skip if it contains redacted private key (user didn't paste new JSON)
244 + if (strpos($service_account_json, self::REDACTED_MARKER) !== false) {
245 + return ['status' => 'unchanged', 'message' => ''];
246 + }
247 +
248 + $validation = $this->validate_service_account_json($service_account_json);
249 + if (!empty($validation['error'])) {
250 + return ['status' => 'error', 'message' => $validation['error']];
251 + }
252 +
253 + if (!google_index_save_service_account($validation['data'])) {
254 + return [
255 + 'status' => 'error',
256 + 'message' => 'Failed to save service account configuration. Please try again or check your JSON format.',
257 + ];
258 + }
259 +
260 + return ['status' => 'saved', 'message' => 'Google Index service account configured successfully!'];
261 + }
262 +
263 + /**
264 + * Whether a function the optional Google Index sub-module provides exists.
265 + *
266 + * Taking the name as a parameter keeps the check opaque to static analysis,
267 + * which otherwise treats the second call in a load-then-verify pair as dead
268 + * because it cannot see that the intervening require_once may define it.
269 + *
270 + * Genuinely impure: the same argument yields a different answer once the
271 + * sub-module has been required, which is precisely why it is called twice.
272 + *
273 + * @phpstan-impure
274 + *
275 + * @param string $name Function name to look for.
276 + * @return bool
277 + */
278 + private function module_function_available($name)
279 + {
280 + return function_exists($name);
281 + }
282 +
283 + /**
284 + * Decode and structurally validate a service account JSON string.
285 + *
286 + * @param string $json Raw JSON text.
287 + * @return array{data:array|null, error:string} error is '' when valid.
288 + */
289 + private function validate_service_account_json($json)
290 + {
291 + $data = json_decode($json, true);
292 +
293 + if (json_last_error() !== JSON_ERROR_NONE) {
294 + $message = 'Invalid JSON format';
295 + switch (json_last_error()) {
296 + case JSON_ERROR_SYNTAX:
297 + $message .= ' - Syntax error in JSON';
298 + break;
299 + case JSON_ERROR_UTF8:
300 + $message .= ' - Invalid UTF-8 encoding';
301 + break;
302 + default:
303 + $message .= ' - ' . json_last_error_msg();
304 + break;
169 305 }
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 - ]);
306 +
307 + return ['data' => null, 'error' => $message . '. Please check your service account JSON.'];
308 + }
309 +
310 + if (!is_array($data)) {
311 + return [
312 + 'data' => null,
313 + 'error' => 'JSON must be an object/array. Please check your service account JSON format.',
314 + ];
315 + }
316 +
317 + $required_fields = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri'];
318 + $missing_fields = [];
319 +
320 + foreach ($required_fields as $field) {
321 + if (!isset($data[$field]) || empty($data[$field])) {
322 + $missing_fields[] = $field;
192 323 }
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 - ]);
324 + }
325 +
326 + if (!empty($missing_fields)) {
327 + return [
328 + 'data' => null,
329 + 'error' => 'Missing required fields - ' . implode(', ', $missing_fields) . '. Please ensure you have a complete service account JSON.',
330 + ];
331 + }
332 +
333 + return ['data' => $data, 'error' => ''];
334 + }
335 +
336 + /**
337 + * AJAX: save the service account from the standalone Instant Indexing page.
338 + *
339 + * The page has no settings form, so this endpoint carries its own nonce and
340 + * capability check rather than relying on a parent save's.
341 + */
342 + public function ajax_save_service_account()
343 + {
344 + $this->authorize_account_request();
345 +
346 + $result = $this->persist_submitted_service_account();
347 +
348 + if ($result['status'] === 'error' || $result['status'] === 'unavailable') {
349 + // Here the credential save IS the whole request, so a missing
350 + // sub-module is a genuine failure and must be reported as one.
351 + wp_send_json_error([
352 + 'message' => $result['message'],
353 + 'errors' => [$result['message']],
354 + ]);
355 + }
356 +
357 + if ($result['status'] === 'unchanged') {
358 + // Nothing was submitted, or the textarea still holds the redacted
359 + // text the server rendered. Reporting this plainly is better than a
360 + // success banner for a save that wrote nothing.
361 + wp_send_json_success([
362 + 'message' => 'No new service account JSON was provided, so the saved configuration is unchanged.',
363 + 'saved' => false,
364 + ]);
365 + }
366 +
367 + // Survives the page reload the JS performs, which is what actually
368 + // renders the confirmation banner and the configured-state UI.
369 + $this->add_settings_notice('Google Index service account configured successfully!', 'success');
370 +
371 + wp_send_json_success([
372 + 'message' => 'Service account saved successfully!',
373 + 'saved' => true,
374 + ]);
375 + }
376 +
377 + /**
378 + * AJAX: clear the stored service account from the standalone page.
379 + */
380 + public function ajax_clear_service_account()
381 + {
382 + $this->authorize_account_request();
383 +
384 + $this->clear_stored_service_account();
385 +
386 + $this->add_settings_notice('Service account configuration cleared successfully!', 'success');
387 +
388 + wp_send_json_success(['message' => 'Service account configuration cleared successfully!']);
389 + }
390 +
391 + /**
392 + * Remove the stored service account and any token minted from it.
393 + *
394 + * Shared by both clear paths: a cached access token that outlives the
395 + * credential it was issued for would keep working until it expired.
396 + */
397 + private function clear_stored_service_account()
398 + {
399 + delete_option('google_index_service_account');
400 +
401 + if (function_exists('google_index_direct')) {
402 + $instance = google_index_direct();
403 + if ($instance) {
404 + $instance->clear_token_cache();
198 405 }
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 406 }
228 407 }
408 +
409 + /**
410 + * Authorize a dedicated (standalone-page) service account request.
411 + *
412 + * Terminates the request with a 403 JSON response when the caller lacks the
413 + * capability or a valid nonce.
414 + */
415 + private function authorize_account_request()
416 + {
417 + if (!current_user_can('manage_options')) {
418 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
419 + }
420 +
421 + $nonce = isset($_POST['metasync_google_index_account_nonce'])
422 + ? wp_unslash($_POST['metasync_google_index_account_nonce'])
423 + : '';
424 +
425 + if (!$nonce || !wp_verify_nonce($nonce, self::ACCOUNT_NONCE_ACTION)) {
426 + wp_send_json_error(array('message' => 'Invalid nonce'), 403);
427 + }
428 +
429 + return true;
430 + }
229 431
230 432 /**
231 433 * Add a settings notice to be displayed after AJAX save
232 434 *
@@ -252,8 +454,11 @@
252 454 public function display_admin_notices()
253 455 {
254 456 // Only show notices on MetaSync settings pages
255 457 $page_slug = $this->get_metasync_page_slug();
458 + if ( ! function_exists( 'get_current_screen' ) ) {
459 + return;
460 + }
256 461 $current_screen = get_current_screen();
257 462
258 463 if (!$current_screen || strpos($current_screen->id, $page_slug) === false) {
259 464 return;
@@ -279,10 +484,10 @@
279 484 */
280 485 public function ajax_test_connection()
281 486 {
282 487 // Check nonce and permissions
283 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'metasync_google_index_direct_test') ||
284 - !Metasync::current_user_has_plugin_access()) {
488 + if (!current_user_can('manage_options') ||
489 + !wp_verify_nonce(wp_unslash($_POST['nonce'] ?? ''), 'metasync_google_index_direct_test')) {
285 490 wp_die('Security check failed');
286 491 }
287 492
288 493 // Load Google Index functionality
@@ -289,9 +494,9 @@
289 494 if (!function_exists('google_index_direct')) {
290 495 if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) {
291 496 require_once plugin_dir_path(__FILE__) . 'google-index-init.php';
292 497 } else {
293 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php');
498 + 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 499 return;
295 500 }
296 501 }
297 502
@@ -310,8 +515,40 @@
310 515 'message' => 'Test failed: ' . $e->getMessage()
311 516 ]);
312 517 }
313 518 }
519 +
520 + /**
521 + * Authorize the shared MetaSync settings AJAX action before secret handling.
522 + */
523 + private function authorize_settings_request()
524 + {
525 + if (!current_user_can('manage_options')) {
526 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
527 + }
528 +
529 + $action = isset($_POST['action']) ? sanitize_key(wp_unslash($_POST['action'])) : '';
530 + $nonce_field = '';
531 + $nonce_action = '';
532 +
533 + if ($action === 'meta_sync_save_settings') {
534 + $nonce_field = 'meta_sync_nonce';
535 + $nonce_action = 'meta_sync_general_setting_nonce';
536 + } elseif ($action === 'meta_sync_save_seo_controls') {
537 + $nonce_field = 'meta_sync_seo_controls_nonce';
538 + $nonce_action = 'meta_sync_seo_controls_nonce';
539 + }
540 +
541 + if (
542 + $nonce_field === ''
543 + || !isset($_POST[$nonce_field])
544 + || !wp_verify_nonce(wp_unslash($_POST[$nonce_field]), $nonce_action)
545 + ) {
546 + wp_send_json_error(array('message' => 'Invalid nonce'), 403);
547 + }
548 +
549 + return true;
550 + }
314 551
315 552 /**
316 553 * Enqueue admin scripts and styles
317 554 */
@@ -321,15 +558,23 @@
321 558 $page_slug = $this->get_metasync_page_slug();
322 559 if (strpos($hook, $page_slug) === false) {
323 560 return;
324 561 }
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') {
329 - return;
562 +
563 + // The credentials section renders in three places: the General Settings
564 + // "general" tab, the Indexation Control page, and the standalone
565 + // Instant Indexing page. Gating on the tab alone left the standalone
566 + // page — the one the ticket is about — without any of this behaviour.
567 + $current_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page and tab check to enqueue scripts
568 + $is_standalone_page = (substr($current_page, -14) === '-instant-index');
569 +
570 + if (!$is_standalone_page) {
571 + $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
572 + if ($current_tab !== 'general' && strpos($hook, 'seo-controls') === false) {
573 + return;
574 + }
330 575 }
331 -
576 +
332 577 // Add inline JavaScript for functionality
333 578 wp_add_inline_script('jquery', $this->get_admin_javascript());
334 579 }
335 580
@@ -343,61 +588,203 @@
343 588 $nonce = wp_create_nonce('metasync_google_index_direct_test');
344 589
345 590 return "
346 591 jQuery(document).ready(function($) {
592 + // What the textarea held immediately before a file overwrote it.
593 + // Seeded from the server-rendered value and re-snapshotted on each
594 + // file pick, so 'Remove' undoes exactly the file selection — it
595 + // does not also discard a credential the user typed by hand before
596 + // mis-clicking Choose File.
597 + var originalJsonValue = $('#google_index_service_account_json').val();
598 +
599 + // FileReader is async and the dedicated Save posts the textarea,
600 + // not the file input. Without this flag a quick Save click after
601 + // choosing a file would send the pre-file contents — on an already
602 + // configured site that is the redacted text, so the save would
603 + // silently do nothing and report 'no new JSON provided'.
604 + var fileReadPending = false;
605 +
606 + // Test Connection builds its results list as an HTML string. The
607 + // values come from the API/server rather than the page, so escape
608 + // them before they reach that sink.
609 + function escHtml(value) {
610 + return $('<div>').text(value === undefined || value === null ? '' : value).html();
611 + }
612 +
613 + function showFileMessage(text, type) {
614 + var box = $('#google-index-file-messages');
615 + if (!box.length) { return; }
616 + box.empty();
617 + if (!text) { return; }
618 + var notice = $('<div>').addClass('notice notice-' + type + ' inline');
619 + notice.append($('<p>').text(text));
620 + box.append(notice);
621 + }
622 +
623 + function showSaveMessage(text, type) {
624 + var box = $('#google-index-save-messages');
625 + if (!box.length) {
626 + // Embedded contexts have no dedicated box; fall back to the
627 + // file-message area so errors are never silent.
628 + showFileMessage(text, type);
629 + return;
630 + }
631 + box.empty();
632 + if (!text) { return; }
633 + var notice = $('<div>').addClass('notice notice-' + type + ' inline');
634 + notice.append($('<p>').text(text));
635 + box.append(notice);
636 + }
637 +
638 + function clearSelectedFile(restoreTextarea) {
639 + fileReadPending = false;
640 + var input = $('#google_index_service_account_file');
641 + if (input.length) {
642 + input.val('');
643 + }
644 + $('#google-index-selected-file').hide();
645 + $('#google-index-selected-file-name').text('');
646 + showFileMessage('', 'info');
647 +
648 + if (restoreTextarea) {
649 + var textarea = $('#google_index_service_account_json');
650 + textarea.val(originalJsonValue);
651 + textarea.trigger('input').trigger('change');
652 + $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change');
653 + }
654 + }
655 +
347 656 // Integration with MetaSync's unsaved changes detection
348 657 function integrateWithUnsavedChangesDetection() {
349 658 // Monitor textarea for changes (avoid recursion)
350 659 $('#google_index_service_account_json').on('input change paste keyup', function(e) {
351 - console.log('Google Index: Textarea changed via', e.type);
352 660 // Trigger MetaSync's change detection (works on both Settings and Indexation Control pages)
353 661 $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change');
354 662 });
355 -
663 +
356 664 // Handle file upload with auto-populate and change detection
357 665 $('#google_index_service_account_file').on('change', function(e) {
358 666 var file = e.target.files[0];
359 667 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');
668 +
669 + if (!file) {
670 + clearSelectedFile(true);
671 + return;
393 672 }
673 +
674 + if (!file.name.toLowerCase().endsWith('.json')) {
675 + // Inline feedback instead of the old blocking alert().
676 + showFileMessage('\"' + file.name + '\" is not a .json file. Choose the service account key file you downloaded from Google Cloud.', 'error');
677 + $('#google-index-selected-file').hide();
678 + $('#google_index_service_account_file').val('');
679 + return;
680 + }
681 +
682 + // Snapshot only when no file is currently selected, i.e. at
683 + // the START of a pick sequence. Re-snapshotting on every
684 + // pick would make Remove restore file A's contents after
685 + // the user picked A then B then removed — leaving a
686 + // credential in the box while the UI says no file is
687 + // selected.
688 + if (!$('#google-index-selected-file').is(':visible')) {
689 + originalJsonValue = textarea.val();
690 + }
691 +
692 + var reader = new FileReader();
693 + fileReadPending = true;
694 + reader.onload = function(ev) {
695 + fileReadPending = false;
696 + try {
697 + var json = JSON.parse(ev.target.result);
698 + var jsonString = JSON.stringify(json, null, 2);
699 + textarea.val(jsonString);
700 +
701 + $('#google-index-selected-file-name').text(file.name);
702 + $('#google-index-selected-file').show();
703 + showFileMessage('\"' + file.name + '\" loaded. Click Save Configuration to store it.', 'info');
704 +
705 + // Create and dispatch native events to ensure proper detection
706 + setTimeout(function() {
707 + var inputEvent = new Event('input', { bubbles: true, cancelable: true });
708 + var changeEvent = new Event('change', { bubbles: true, cancelable: true });
709 +
710 + textarea[0].dispatchEvent(inputEvent);
711 + textarea[0].dispatchEvent(changeEvent);
712 +
713 + textarea.trigger('input').trigger('change');
714 + }, 100);
715 + } catch (err) {
716 + showFileMessage('\"' + file.name + '\" is not valid JSON. Re-download the key file from Google Cloud and try again.', 'error');
717 + $('#google-index-selected-file').hide();
718 + $('#google_index_service_account_file').val('');
719 + }
720 + };
721 + reader.onerror = function() {
722 + fileReadPending = false;
723 + showFileMessage('\"' + file.name + '\" could not be read. Try choosing it again.', 'error');
724 + $('#google-index-selected-file').hide();
725 + $('#google_index_service_account_file').val('');
726 + };
727 + reader.readAsText(file);
394 728 });
729 +
730 + // Remove/reselect
731 + $('#google-index-remove-file').on('click', function(e) {
732 + e.preventDefault();
733 + clearSelectedFile(true);
734 + });
395 735 }
396 -
736 +
397 737 // Initialize integration after a small delay to ensure MetaSync is ready
398 738 setTimeout(integrateWithUnsavedChangesDetection, 100);
399 -
739 +
740 + // Dedicated save — standalone Instant Indexing page only.
741 + $('#google-index-save-config').on('click', function(e) {
742 + e.preventDefault();
743 +
744 + if (fileReadPending) {
745 + showSaveMessage('Still reading the selected file. Try again in a moment.', 'warning');
746 + return;
747 + }
748 +
749 + var button = $(this);
750 + var original = button.html();
751 + button.prop('disabled', true).text('Saving...');
752 + showSaveMessage('', 'info');
753 +
754 + $.ajax({
755 + url: (typeof ajaxurl !== 'undefined') ? ajaxurl : metaSync.ajax_url,
756 + type: 'POST',
757 + data: {
758 + action: 'metasync_google_index_save_account',
759 + metasync_google_index_account_nonce: $('#metasync_google_index_account_nonce').val(),
760 + google_index_service_account_json: $('#google_index_service_account_json').val()
761 + },
762 + success: function(response) {
763 + if (response && response.success) {
764 + if (response.data && response.data.saved) {
765 + // Reload so the configured-state UI and the
766 + // transient success banner both render.
767 + window.location.reload();
768 + return;
769 + }
770 + showSaveMessage((response.data && response.data.message) || 'Nothing to save.', 'warning');
771 + } else {
772 + var msg = (response && response.data && response.data.message)
773 + ? response.data.message
774 + : 'Failed to save the service account.';
775 + showSaveMessage(msg, 'error');
776 + }
777 + },
778 + error: function() {
779 + showSaveMessage('Network error while saving. Please try again.', 'error');
780 + },
781 + complete: function() {
782 + button.prop('disabled', false).html(original);
783 + }
784 + });
785 + });
786 +
400 787 // Handle test connection button
401 788 $('#google-index-test-connection').on('click', function(e) {
402 789 e.preventDefault();
403 790
@@ -426,10 +813,10 @@
426 813 html += '<li><strong>Token Cached:</strong> ' + (results.token_test.cached ? '✅ Yes' : '⚪ No') + '</li>';
427 814 }
428 815
429 816 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>';
817 + html += '<li><strong>Service Account:</strong> ' + escHtml(results.credentials_test.client_email) + '</li>';
818 + html += '<li><strong>Project ID:</strong> ' + escHtml(results.credentials_test.project_id) + '</li>';
432 819 html += '<li><strong>Private Key:</strong> ' + (results.credentials_test.has_private_key ? '✅ Present' : '❌ Missing') + '</li>';
433 820 }
434 821
435 822 if (results.homepage_test) {
@@ -435,11 +822,11 @@
435 822 if (results.homepage_test) {
436 823 if (results.homepage_test.success) {
437 824 html += '<li><strong>Homepage Status:</strong> ✅ Success</li>';
438 825 } else {
439 - html += '<li><strong>Homepage Status:</strong> ⚠️ ' + results.homepage_test.error.message + '</li>';
826 + html += '<li><strong>Homepage Status:</strong> ⚠️ ' + escHtml(results.homepage_test.error.message) + '</li>';
440 827 if (results.homepage_test.note) {
441 - html += '<li><strong>Note:</strong> ' + results.homepage_test.note + '</li>';
828 + html += '<li><strong>Note:</strong> ' + escHtml(results.homepage_test.note) + '</li>';
442 829 }
443 830 }
444 831 }
445 832
@@ -445,9 +832,9 @@
445 832
446 833 html += '</ul></div>';
447 834 resultDiv.html(html);
448 835 } else {
449 - resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Test Failed:</strong> ' + response.data.message + '</p></div>');
836 + resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Test Failed:</strong> ' + escHtml(response.data.message) + '</p></div>');
450 837 }
451 838 },
452 839 error: function() {
453 840 resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Connection Error:</strong> Unable to perform test.</p></div>');
@@ -460,24 +847,56 @@
460 847
461 848 // Handle clear configuration button
462 849 $('#google-index-clear-config').on('click', function(e) {
463 850 e.preventDefault();
464 -
851 +
465 852 if (!confirm('Are you sure you want to clear the service account configuration?')) {
466 853 return;
467 854 }
468 -
469 - // Create hidden input to indicate clear configuration request
855 +
856 + var dedicatedNonce = $('#metasync_google_index_account_nonce').val();
857 +
858 + if (dedicatedNonce) {
859 + // Standalone Instant Indexing page: there is no settings
860 + // form to serialize, so post to the dedicated endpoint.
861 + $.ajax({
862 + url: (typeof ajaxurl !== 'undefined') ? ajaxurl : metaSync.ajax_url,
863 + type: 'POST',
864 + data: {
865 + action: 'metasync_google_index_clear_account',
866 + metasync_google_index_account_nonce: dedicatedNonce
867 + },
868 + success: function(response) {
869 + if (response && response.success) {
870 + window.location.reload();
871 + return;
872 + }
873 + var msg = (response && response.data && response.data.message)
874 + ? response.data.message
875 + : 'Failed to clear the service account.';
876 + showSaveMessage(msg, 'error');
877 + },
878 + error: function() {
879 + showSaveMessage('Network error while clearing. Please try again.', 'error');
880 + }
881 + });
882 + return;
883 + }
884 +
885 + // Embedded contexts: piggyback on the page's settings form.
470 886 var input = $('<input>')
471 887 .attr('type', 'hidden')
472 888 .attr('name', 'google_index_clear_config')
473 889 .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');
890 +
891 + // Append to the correct form (Indexation Control vs General Settings) and submit via AJAX
892 + var clrForm = $('#metaSyncSeoControlsForm').length ? $('#metaSyncSeoControlsForm') : $('#metaSyncGeneralSetting');
893 + var clrAction = $('#metaSyncSeoControlsForm').length ? 'meta_sync_save_seo_controls' : 'meta_sync_save_settings';
894 + clrForm.append(input);
895 + var clrData = clrForm.serialize() + '&action=' + clrAction;
896 +
897 + // Submit the clear request directly via AJAX, then reload
898 + $.ajax({ url: ajaxurl || metaSync.ajax_url, type: 'POST', data: clrData, complete: function() { window.location.reload(); } });
480 899 });
481 900
482 901 });
483 902 ";