| @@ -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 | "; |