| @@ -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,11 +141,11 @@ | ||
| 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 | |
| 129 | 151 | if (!$this->authorize_settings_request()) { |
| @@ -130,108 +152,283 @@ | ||
| 130 | 152 | return; |
| 131 | 153 | } |
| 132 | 154 | |
| 133 | 155 | // Clearing does not need to load or inspect credential contents. |
| 134 | - if (isset($_POST['google_index_clear_config'])) { | |
| 135 | - delete_option('google_index_service_account'); | |
| 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(); | |
| 136 | 158 | $this->add_settings_notice('Service account configuration cleared successfully!', 'success'); |
| 137 | 159 | return; |
| 138 | 160 | } |
| 139 | - | |
| 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 | + { | |
| 140 | 202 | // Load Google Index functionality |
| 141 | - if (!function_exists('google_index_save_service_account')) { | |
| 203 | + if (!$this->module_function_available('google_index_save_service_account')) { | |
| 142 | 204 | if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) { |
| 143 | 205 | require_once plugin_dir_path(__FILE__) . 'google-index-init.php'; |
| 144 | - } else { | |
| 145 | - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(__FILE__) . 'google-index-init.php'); | |
| 146 | - return; | |
| 147 | 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 | + } | |
| 148 | 219 | } |
| 149 | - | |
| 220 | + | |
| 150 | 221 | $service_account_json = ''; |
| 151 | - | |
| 222 | + | |
| 152 | 223 | // Get JSON from textarea |
| 153 | - if (isset($_POST['google_index_service_account_json'])) { | |
| 154 | - $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 | |
| 155 | 226 | } |
| 156 | - | |
| 227 | + | |
| 157 | 228 | // Override with file upload if provided |
| 158 | - if (isset($_FILES['google_index_service_account_file']) && | |
| 159 | - !empty($_FILES['google_index_service_account_file']['tmp_name']) && | |
| 160 | - file_exists($_FILES['google_index_service_account_file']['tmp_name'])) { | |
| 161 | - | |
| 162 | - $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 | |
| 163 | 234 | if ($uploaded_json !== false) { |
| 164 | 235 | $service_account_json = $uploaded_json; // Removed unnecessary wp_unslash |
| 165 | 236 | } |
| 166 | 237 | } |
| 167 | - | |
| 168 | - // Process service account JSON if provided | |
| 169 | - if (!empty($service_account_json) && trim($service_account_json) !== '') { | |
| 170 | - | |
| 171 | - // Skip if it contains redacted private key (user didn't paste new JSON) | |
| 172 | - if (strpos($service_account_json, '-----REDACTED-----') !== false) { | |
| 173 | - return; // Don't process redacted display text | |
| 238 | + | |
| 239 | + if (empty($service_account_json) || trim($service_account_json) === '') { | |
| 240 | + return ['status' => 'unchanged', 'message' => '']; | |
| 241 | + } | |
| 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; | |
| 174 | 305 | } |
| 175 | - | |
| 176 | - // Decode and validate JSON | |
| 177 | - $service_account_data = json_decode($service_account_json, true); | |
| 178 | - | |
| 179 | - if (json_last_error() !== JSON_ERROR_NONE) { | |
| 180 | - // JSON parsing error - send immediate error response | |
| 181 | - $json_error_message = 'Invalid JSON format'; | |
| 182 | - switch (json_last_error()) { | |
| 183 | - case JSON_ERROR_SYNTAX: | |
| 184 | - $json_error_message .= ' - Syntax error in JSON'; | |
| 185 | - break; | |
| 186 | - case JSON_ERROR_UTF8: | |
| 187 | - $json_error_message .= ' - Invalid UTF-8 encoding'; | |
| 188 | - break; | |
| 189 | - default: | |
| 190 | - $json_error_message .= ' - ' . json_last_error_msg(); | |
| 191 | - break; | |
| 192 | - } | |
| 193 | - | |
| 194 | - wp_send_json_error([ | |
| 195 | - 'errors' => ['Google Index: ' . $json_error_message . '. Please check your service account JSON.'] | |
| 196 | - ]); | |
| 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; | |
| 197 | 323 | } |
| 198 | - | |
| 199 | - if (!is_array($service_account_data)) { | |
| 200 | - wp_send_json_error([ | |
| 201 | - 'errors' => ['Google Index: JSON must be an object/array. Please check your service account JSON format.'] | |
| 202 | - ]); | |
| 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(); | |
| 203 | 405 | } |
| 204 | - | |
| 205 | - // Validate required fields | |
| 206 | - $required_fields = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri']; | |
| 207 | - $missing_fields = []; | |
| 208 | - | |
| 209 | - foreach ($required_fields as $field) { | |
| 210 | - if (!isset($service_account_data[$field]) || empty($service_account_data[$field])) { | |
| 211 | - $missing_fields[] = $field; | |
| 212 | - } | |
| 213 | - } | |
| 214 | - | |
| 215 | - if (!empty($missing_fields)) { | |
| 216 | - wp_send_json_error([ | |
| 217 | - 'errors' => ['Google Index: Missing required fields - ' . implode(', ', $missing_fields) . '. Please ensure you have a complete service account JSON.'] | |
| 218 | - ]); | |
| 219 | - } | |
| 220 | - | |
| 221 | - // Save using Google Index function | |
| 222 | - $result = google_index_save_service_account($service_account_data); | |
| 223 | - | |
| 224 | - if ($result) { | |
| 225 | - $this->add_settings_notice('Google Index service account configured successfully!', 'success'); | |
| 226 | - } else { | |
| 227 | - // Send immediate error response using MetaSync's expected format | |
| 228 | - wp_send_json_error([ | |
| 229 | - 'errors' => ['Google Index: Failed to save service account configuration. Please try again or check your JSON format.'] | |
| 230 | - ]); | |
| 231 | - } | |
| 232 | 406 | } |
| 233 | 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 | + } | |
| 234 | 431 | |
| 235 | 432 | /** |
| 236 | 433 | * Add a settings notice to be displayed after AJAX save |
| 237 | 434 | * |
| @@ -297,9 +494,9 @@ | ||
| 297 | 494 | if (!function_exists('google_index_direct')) { |
| 298 | 495 | if (file_exists(plugin_dir_path(__FILE__) . 'google-index-init.php')) { |
| 299 | 496 | require_once plugin_dir_path(__FILE__) . 'google-index-init.php'; |
| 300 | 497 | } else { |
| 301 | - 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 | |
| 302 | 499 | return; |
| 303 | 500 | } |
| 304 | 501 | } |
| 305 | 502 | |
| @@ -361,15 +558,23 @@ | ||
| 361 | 558 | $page_slug = $this->get_metasync_page_slug(); |
| 362 | 559 | if (strpos($hook, $page_slug) === false) { |
| 363 | 560 | return; |
| 364 | 561 | } |
| 365 | - | |
| 366 | - // Only load on general tab (default tab when none specified) | |
| 367 | - $current_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general'; | |
| 368 | - if ($current_tab !== 'general') { | |
| 369 | - 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 | + } | |
| 370 | 575 | } |
| 371 | - | |
| 576 | + | |
| 372 | 577 | // Add inline JavaScript for functionality |
| 373 | 578 | wp_add_inline_script('jquery', $this->get_admin_javascript()); |
| 374 | 579 | } |
| 375 | 580 | |
| @@ -383,61 +588,203 @@ | ||
| 383 | 588 | $nonce = wp_create_nonce('metasync_google_index_direct_test'); |
| 384 | 589 | |
| 385 | 590 | return " |
| 386 | 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 | + | |
| 387 | 656 | // Integration with MetaSync's unsaved changes detection |
| 388 | 657 | function integrateWithUnsavedChangesDetection() { |
| 389 | 658 | // Monitor textarea for changes (avoid recursion) |
| 390 | 659 | $('#google_index_service_account_json').on('input change paste keyup', function(e) { |
| 391 | - console.log('Google Index: Textarea changed via', e.type); | |
| 392 | 660 | // Trigger MetaSync's change detection (works on both Settings and Indexation Control pages) |
| 393 | 661 | $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change'); |
| 394 | 662 | }); |
| 395 | - | |
| 663 | + | |
| 396 | 664 | // Handle file upload with auto-populate and change detection |
| 397 | 665 | $('#google_index_service_account_file').on('change', function(e) { |
| 398 | 666 | var file = e.target.files[0]; |
| 399 | 667 | var textarea = $('#google_index_service_account_json'); |
| 400 | - | |
| 401 | - if (file && file.name.toLowerCase().endsWith('.json')) { | |
| 402 | - var reader = new FileReader(); | |
| 403 | - reader.onload = function(e) { | |
| 404 | - try { | |
| 405 | - var json = JSON.parse(e.target.result); | |
| 406 | - // Update textarea value | |
| 407 | - var jsonString = JSON.stringify(json, null, 2); | |
| 408 | - textarea.val(jsonString); | |
| 409 | - | |
| 410 | - // Create and dispatch native events to ensure proper detection | |
| 411 | - setTimeout(function() { | |
| 412 | - // Create native events | |
| 413 | - var inputEvent = new Event('input', { bubbles: true, cancelable: true }); | |
| 414 | - var changeEvent = new Event('change', { bubbles: true, cancelable: true }); | |
| 415 | - | |
| 416 | - // Dispatch events on the actual DOM element (not jQuery) | |
| 417 | - textarea[0].dispatchEvent(inputEvent); | |
| 418 | - textarea[0].dispatchEvent(changeEvent); | |
| 419 | - | |
| 420 | - // Also trigger jQuery events as backup | |
| 421 | - textarea.trigger('input').trigger('change'); | |
| 422 | - | |
| 423 | - console.log('Google Index: File upload triggered change detection'); | |
| 424 | - }, 100); | |
| 425 | - } catch (err) { | |
| 426 | - alert('Invalid JSON file selected.'); | |
| 427 | - } | |
| 428 | - }; | |
| 429 | - reader.readAsText(file); | |
| 430 | - } else { | |
| 431 | - // File upload indicates change even if not JSON (works on both Settings and Indexation Control pages) | |
| 432 | - $('#metaSyncGeneralSetting, #metaSyncSeoControlsForm').trigger('change'); | |
| 668 | + | |
| 669 | + if (!file) { | |
| 670 | + clearSelectedFile(true); | |
| 671 | + return; | |
| 433 | 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); | |
| 434 | 728 | }); |
| 729 | + | |
| 730 | + // Remove/reselect | |
| 731 | + $('#google-index-remove-file').on('click', function(e) { | |
| 732 | + e.preventDefault(); | |
| 733 | + clearSelectedFile(true); | |
| 734 | + }); | |
| 435 | 735 | } |
| 436 | - | |
| 736 | + | |
| 437 | 737 | // Initialize integration after a small delay to ensure MetaSync is ready |
| 438 | 738 | setTimeout(integrateWithUnsavedChangesDetection, 100); |
| 439 | - | |
| 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 | + | |
| 440 | 787 | // Handle test connection button |
| 441 | 788 | $('#google-index-test-connection').on('click', function(e) { |
| 442 | 789 | e.preventDefault(); |
| 443 | 790 | |
| @@ -466,10 +813,10 @@ | ||
| 466 | 813 | html += '<li><strong>Token Cached:</strong> ' + (results.token_test.cached ? '✅ Yes' : '⚪ No') + '</li>'; |
| 467 | 814 | } |
| 468 | 815 | |
| 469 | 816 | if (results.credentials_test) { |
| 470 | - html += '<li><strong>Service Account:</strong> ' + results.credentials_test.client_email + '</li>'; | |
| 471 | - 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>'; | |
| 472 | 819 | html += '<li><strong>Private Key:</strong> ' + (results.credentials_test.has_private_key ? '✅ Present' : '❌ Missing') + '</li>'; |
| 473 | 820 | } |
| 474 | 821 | |
| 475 | 822 | if (results.homepage_test) { |
| @@ -475,11 +822,11 @@ | ||
| 475 | 822 | if (results.homepage_test) { |
| 476 | 823 | if (results.homepage_test.success) { |
| 477 | 824 | html += '<li><strong>Homepage Status:</strong> ✅ Success</li>'; |
| 478 | 825 | } else { |
| 479 | - 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>'; | |
| 480 | 827 | if (results.homepage_test.note) { |
| 481 | - html += '<li><strong>Note:</strong> ' + results.homepage_test.note + '</li>'; | |
| 828 | + html += '<li><strong>Note:</strong> ' + escHtml(results.homepage_test.note) + '</li>'; | |
| 482 | 829 | } |
| 483 | 830 | } |
| 484 | 831 | } |
| 485 | 832 | |
| @@ -485,9 +832,9 @@ | ||
| 485 | 832 | |
| 486 | 833 | html += '</ul></div>'; |
| 487 | 834 | resultDiv.html(html); |
| 488 | 835 | } else { |
| 489 | - 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>'); | |
| 490 | 837 | } |
| 491 | 838 | }, |
| 492 | 839 | error: function() { |
| 493 | 840 | resultDiv.html('<div class=\"notice notice-error inline\"><p><strong>❌ Connection Error:</strong> Unable to perform test.</p></div>'); |
| @@ -500,19 +847,48 @@ | ||
| 500 | 847 | |
| 501 | 848 | // Handle clear configuration button |
| 502 | 849 | $('#google-index-clear-config').on('click', function(e) { |
| 503 | 850 | e.preventDefault(); |
| 504 | - | |
| 851 | + | |
| 505 | 852 | if (!confirm('Are you sure you want to clear the service account configuration?')) { |
| 506 | 853 | return; |
| 507 | 854 | } |
| 508 | - | |
| 509 | - // 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. | |
| 510 | 886 | var input = $('<input>') |
| 511 | 887 | .attr('type', 'hidden') |
| 512 | 888 | .attr('name', 'google_index_clear_config') |
| 513 | 889 | .attr('value', '1'); |
| 514 | - | |
| 890 | + | |
| 515 | 891 | // Append to the correct form (Indexation Control vs General Settings) and submit via AJAX |
| 516 | 892 | var clrForm = $('#metaSyncSeoControlsForm').length ? $('#metaSyncSeoControlsForm') : $('#metaSyncGeneralSetting'); |
| 517 | 893 | var clrAction = $('#metaSyncSeoControlsForm').length ? 'meta_sync_save_seo_controls' : 'meta_sync_save_settings'; |
| 518 | 894 | clrForm.append(input); |