← All changes
|
customer-sync-requests/class-metasync-sync-requests.php
+145
-91
2.6.14
→
2.7.1
View file →
| @@ -46,9 +46,9 @@ | ||
| 46 | 46 | * 'heartbeat' for JS heartbeat ticks, '' for |
| 47 | 47 | * system callers (settings-save verification, |
| 48 | 48 | * cron connectivity test, category CRUD, REST). |
| 49 | 49 | * Only 'manual' consumes/stamps the manual |
| 50 | - * cooldown (WP-426). | |
| 50 | + * cooldown. | |
| 51 | 51 | */ |
| 52 | 52 | public function SyncCustomerParams($token = null, $context = '') |
| 53 | 53 | { |
| 54 | 54 | $categories_sync_limit = 1000; |
| @@ -67,14 +67,19 @@ | ||
| 67 | 67 | |
| 68 | 68 | # From Feature Issue #132 |
| 69 | 69 | # We need to alter this url based on API key |
| 70 | 70 | # so let's fethc the api key first |
| 71 | - | |
| 72 | - $api_key = $general_options['searchatlas_api_key'] ?? null; | |
| 73 | 71 | |
| 72 | + # Decrypt the stored key for use as the x-api-key value. false => the | |
| 73 | + # stored key could not be decrypted (salts changed); treat as no key. | |
| 74 | + $api_key = Metasync::get_searchatlas_api_key(); | |
| 75 | + if ($api_key === false) { | |
| 76 | + $api_key = ''; | |
| 77 | + } | |
| 78 | + | |
| 74 | 79 | #check that the api key is not empty |
| 75 | 80 | |
| 76 | - if ($api_key == null){ | |
| 81 | + if ($api_key === ''){ | |
| 77 | 82 | return false; |
| 78 | 83 | } |
| 79 | 84 | |
| 80 | 85 | # last hb request — read from dedicated throttle option so we are not |
| @@ -82,10 +87,10 @@ | ||
| 82 | 87 | $_throttle = Metasync::get_heartbeat_throttle(); |
| 83 | 88 | $last_hb_request_time = $_throttle['last_heart_beat'] ?? 0; |
| 84 | 89 | |
| 85 | 90 | # PR3: Throttle depends on state — burst (KEY_PENDING within 30 min) allows 30s; else 5 min |
| 86 | - $is_heartbeat = ($context === 'heartbeat') || filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN); | |
| 87 | - $is_burst = !empty($_POST['is_burst']); | |
| 91 | + $is_heartbeat = ($context === 'heartbeat') || filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only throttle flags, callers are already authorized | |
| 92 | + $is_burst = !empty($_POST['is_burst']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only throttle flags, callers are already authorized | |
| 88 | 93 | $heartbeat_state = $metasync_options['general']['heartbeat_state'] ?? ''; |
| 89 | 94 | $state_changed_at = (int) ($metasync_options['general']['heartbeat_state_changed_at'] ?? 0); |
| 90 | 95 | $burst_window_end = $state_changed_at + (30 * 60); // 30 min cap |
| 91 | 96 | $in_burst_window = ($heartbeat_state === 'KEY_PENDING' && $burst_window_end > time()); |
| @@ -107,9 +112,9 @@ | ||
| 107 | 112 | } elseif ($context === 'manual') { |
| 108 | 113 | # Manual "Sync Now" path: track throttle via a dedicated option key so |
| 109 | 114 | # heartbeat ticks (which update last_heart_beat every ~15s) cannot keep |
| 110 | 115 | # the manual cooldown alive indefinitely. |
| 111 | - # WP-426: only the Settings "Sync Now" button takes this branch. System | |
| 116 | + # only the Settings "Sync Now" button takes this branch. System | |
| 112 | 117 | # callers (API-key save verification, cron connectivity test, category |
| 113 | 118 | # CRUD, REST sync) must neither be rejected by the manual cooldown — |
| 114 | 119 | # a throttled object reads as a failed verification and reverts a |
| 115 | 120 | # freshly saved API key — nor stamp it, which would lock the button |
| @@ -157,86 +162,14 @@ | ||
| 157 | 162 | |
| 158 | 163 | $new_categories = $this->post_categories(); |
| 159 | 164 | $this->saveHeartBeatError('categories', 'The limit of categories is exceeded', $new_categories, $categories_sync_limit); |
| 160 | 165 | |
| 161 | - # $users = get_users(); | |
| 162 | - # $new_users = []; | |
| 163 | - # Get selected roles for Content Genius sync with safety checks | |
| 164 | - $selected_roles = isset($general_options['content_genius_sync_roles']) && is_array($general_options['content_genius_sync_roles']) | |
| 165 | - ? $general_options['content_genius_sync_roles'] | |
| 166 | - : array(); | |
| 167 | - | |
| 168 | - # If it's a string (single role from old version), convert to array | |
| 169 | - if (!is_array($selected_roles)) { | |
| 170 | - $selected_roles = !empty($selected_roles) ? array($selected_roles) : array(); | |
| 171 | - } | |
| 172 | - | |
| 173 | - # Sanitize role values to prevent injection | |
| 174 | - $selected_roles = array_map('sanitize_key', $selected_roles); | |
| 175 | - | |
| 176 | - # Prepare optimized user query arguments - only fetch required fields | |
| 177 | - $user_query_args = array( | |
| 178 | - 'number' => $users_sync_limit, | |
| 179 | - 'fields' => array('ID', 'user_login', 'user_email'), // Only fetch needed fields for performance | |
| 180 | - 'orderby' => 'ID', | |
| 181 | - 'order' => 'ASC' | |
| 182 | - ); | |
| 183 | - | |
| 184 | - # If specific roles are selected and "all" is not selected, filter by those roles | |
| 185 | - if (!empty($selected_roles) && !in_array('all', $selected_roles, true)) { | |
| 186 | - # Only add role filter if we have valid roles | |
| 187 | - $valid_roles = array_filter($selected_roles, function($role) { | |
| 188 | - return !empty($role) && $role !== 'all'; | |
| 189 | - }); | |
| 190 | - | |
| 191 | - if (!empty($valid_roles)) { | |
| 192 | - $user_query_args['role__in'] = $valid_roles; | |
| 193 | - } | |
| 194 | - } | |
| 195 | - | |
| 196 | - # Fetch users based on the selected roles with error handling | |
| 197 | - $users = get_users($user_query_args); | |
| 198 | - | |
| 199 | - # Safety check: ensure $users is an array | |
| 200 | - if (!is_array($users)) { | |
| 201 | - $users = array(); | |
| 202 | - } | |
| 203 | - | |
| 204 | - $new_users = array(); | |
| 205 | - $user_count = 1; | |
| 206 | - # Get the default user role from WordPress settings | |
| 207 | - # $default_role = get_option('default_role'); | |
| 166 | + # Build the users payload. Honors the Content Genius role filter | |
| 167 | + # (content_genius_sync_roles) but falls back to all users when the filter | |
| 168 | + # matches nobody — an empty users list makes the backend reject the whole | |
| 169 | + # sync with HTTP 400. See build_users_payload(). | |
| 170 | + $new_users = $this->build_users_payload($general_options); | |
| 208 | 171 | |
| 209 | - # Get the default user role from WordPress settings (fallback to administrator) | |
| 210 | - $default_role = get_option('default_role', 'administrator'); | |
| 211 | - | |
| 212 | - foreach ($users as $user) { | |
| 213 | - if ($user_count <= $users_sync_limit) { | |
| 214 | - | |
| 215 | - $user_data = get_userdata($user->ID); | |
| 216 | - # Skip if user data is invalid | |
| 217 | - if (!$user_data || !is_object($user_data)) { | |
| 218 | - continue; | |
| 219 | - } | |
| 220 | - | |
| 221 | - # Get user role with proper safety checks | |
| 222 | - $user_role = $default_role; | |
| 223 | - if (is_array($user_data->roles) && !empty($user_data->roles)) { | |
| 224 | - $user_role = isset($user_data->roles[0]) ? $user_data->roles[0] : $default_role; | |
| 225 | - } | |
| 226 | - | |
| 227 | - # Prepare user data with proper sanitization | |
| 228 | - # Using data from optimized query (ID, user_login, user_email already fetched) | |
| 229 | - $new_users[] = array( | |
| 230 | - 'id' => absint($user->ID), | |
| 231 | - 'user_login' => isset($user->user_login) ? sanitize_user($user->user_login) : '', | |
| 232 | - 'user_email' => isset($user->user_email) ? sanitize_email($user->user_email) : '', | |
| 233 | - 'role' => sanitize_key($user_role) | |
| 234 | - ); | |
| 235 | - } | |
| 236 | - $user_count++; | |
| 237 | - } | |
| 238 | - | |
| 239 | 172 | $this->saveHeartBeatError('users', 'The limit of users is exceeded', $new_users, $users_sync_limit); |
| 240 | 173 | $current_permalink_structure = get_option('permalink_structure'); |
| 241 | 174 | $current_rewrite_rules = get_option('rewrite_rules'); |
| 242 | 175 | |
| @@ -249,8 +182,12 @@ | ||
| 249 | 182 | 'permalink_structure'=>((($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '')?false:true), |
| 250 | 183 | 'otto_pixel_uuid' => $general_options['otto_pixel_uuid'] ?? '', |
| 251 | 184 | ]; |
| 252 | 185 | |
| 186 | + if (Metasync_Headless_Config::is_active()) { | |
| 187 | + $payload['frontend_domain'] = Metasync_Headless_Config::get_frontend_domain(); | |
| 188 | + } | |
| 189 | + | |
| 253 | 190 | # append login auth token to payload |
| 254 | 191 | if(!empty($token)){ |
| 255 | 192 | $payload['login_auth_token'] = $token; |
| 256 | 193 | } |
| @@ -257,9 +194,9 @@ | ||
| 257 | 194 | |
| 258 | 195 | $data = [ |
| 259 | 196 | 'body' => $payload, |
| 260 | 197 | 'headers' => [ |
| 261 | - 'x-api-key' => $general_options['searchatlas_api_key'], | |
| 198 | + 'x-api-key' => $api_key, | |
| 262 | 199 | |
| 263 | 200 | ], |
| 264 | 201 | # PERFORMANCE OPTIMIZATION: Add timeout for sync operations |
| 265 | 202 | 'timeout' => 15, |
| @@ -268,9 +205,9 @@ | ||
| 268 | 205 | $response = wp_remote_post($apiUrl, $data); |
| 269 | 206 | |
| 270 | 207 | # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors |
| 271 | 208 | if (is_wp_error($response)) { |
| 272 | - error_log('MetaSync: Heartbeat sync failed: ' . $response->get_error_message()); | |
| 209 | + error_log('MetaSync: Heartbeat sync failed: ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 273 | 210 | $this->saveHeartBeatError('heartbeat', 'Connection error: ' . $response->get_error_message(), array(1), 0); |
| 274 | 211 | return; |
| 275 | 212 | } |
| 276 | 213 | |
| @@ -296,13 +233,13 @@ | ||
| 296 | 233 | $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']); |
| 297 | 234 | if (!empty($response_body['uuid_mismatch'])) { |
| 298 | 235 | # Domain clone: backend says local UUID is wrong for this domain |
| 299 | 236 | $_sync_updates['otto_pixel_uuid'] = $response_uuid; |
| 300 | - error_log('MetaSync: UUID corrected from ' . $current_uuid . ' to ' . $response_uuid . ' (domain clone detected)'); | |
| 237 | + error_log('MetaSync: UUID corrected from ' . $current_uuid . ' to ' . $response_uuid . ' (domain clone detected)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 301 | 238 | } elseif (empty($current_uuid)) { |
| 302 | 239 | # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat |
| 303 | 240 | $_sync_updates['otto_pixel_uuid'] = $response_uuid; |
| 304 | - error_log('MetaSync: UUID set from heartbeat response (self-heal after SSO callback missed)'); | |
| 241 | + error_log('MetaSync: UUID set from heartbeat response (self-heal after SSO callback missed)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 305 | 242 | } |
| 306 | 243 | } |
| 307 | 244 | |
| 308 | 245 | # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility) |
| @@ -328,8 +265,119 @@ | ||
| 328 | 265 | |
| 329 | 266 | return $response; |
| 330 | 267 | } |
| 331 | 268 | } |
| 269 | + /** | |
| 270 | + * Build the users payload for the heartbeat/sync request. | |
| 271 | + * | |
| 272 | + * Honors the Content Genius role filter (content_genius_sync_roles). The | |
| 273 | + * critical guarantee: the returned list is never left empty because the | |
| 274 | + * role filter matched nobody. The Search Atlas heartbeat endpoint rejects | |
| 275 | + * an empty `users` list with HTTP 400 ("users: This field is required."), | |
| 276 | + * which fails the ENTIRE sync — flipping the site to "Not Connected" and | |
| 277 | + * blocking Content Genius. So when a role filter is configured but matches | |
| 278 | + * zero users on the site, we fall back to all users (the same set sent when | |
| 279 | + * no filter is configured) so a role preference can never sever the | |
| 280 | + * connection. (If the site genuinely has no users at all, the list can | |
| 281 | + * still be empty — but that cannot happen on a real WordPress install.) | |
| 282 | + * | |
| 283 | + * @param array $general_options The plugin 'general' options array. | |
| 284 | + * @param callable|null $user_fetcher Fetches users for a WP_User_Query | |
| 285 | + * args array; defaults to get_users(). | |
| 286 | + * Injected in tests. | |
| 287 | + * @param callable|null $userdata_fetcher Fetches a WP_User by ID; defaults to | |
| 288 | + * get_userdata(). Injected in tests. | |
| 289 | + * @return array<int,array{id:int,user_login:string,user_email:string,role:string}> | |
| 290 | + */ | |
| 291 | + public function build_users_payload($general_options, ?callable $user_fetcher = null, ?callable $userdata_fetcher = null) | |
| 292 | + { | |
| 293 | + $users_sync_limit = 1000; | |
| 294 | + $user_fetcher = $user_fetcher ?? 'get_users'; | |
| 295 | + $userdata_fetcher = $userdata_fetcher ?? 'get_userdata'; | |
| 296 | + | |
| 297 | + # Get selected roles for Content Genius sync with safety checks | |
| 298 | + $selected_roles = isset($general_options['content_genius_sync_roles']) && is_array($general_options['content_genius_sync_roles']) | |
| 299 | + ? $general_options['content_genius_sync_roles'] | |
| 300 | + : array(); | |
| 301 | + | |
| 302 | + # If it's a string (single role from old version), convert to array | |
| 303 | + if (!is_array($selected_roles)) { | |
| 304 | + $selected_roles = !empty($selected_roles) ? array($selected_roles) : array(); | |
| 305 | + } | |
| 306 | + | |
| 307 | + # Sanitize role values to prevent injection | |
| 308 | + $selected_roles = array_map('sanitize_key', $selected_roles); | |
| 309 | + | |
| 310 | + # Prepare optimized user query arguments - only fetch required fields | |
| 311 | + $user_query_args = array( | |
| 312 | + 'number' => $users_sync_limit, | |
| 313 | + 'fields' => array('ID', 'user_login', 'user_email'), // Only fetch needed fields for performance | |
| 314 | + 'orderby' => 'ID', | |
| 315 | + 'order' => 'ASC', | |
| 316 | + ); | |
| 317 | + | |
| 318 | + # If specific roles are selected and "all" is not selected, filter by those roles | |
| 319 | + if (!empty($selected_roles) && !in_array('all', $selected_roles, true)) { | |
| 320 | + $valid_roles = array_filter($selected_roles, function ($role) { | |
| 321 | + return !empty($role) && $role !== 'all'; | |
| 322 | + }); | |
| 323 | + if (!empty($valid_roles)) { | |
| 324 | + $user_query_args['role__in'] = $valid_roles; | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 328 | + # Fetch users based on the selected roles with error handling | |
| 329 | + $users = $user_fetcher($user_query_args); | |
| 330 | + if (!is_array($users)) { | |
| 331 | + $users = array(); | |
| 332 | + } | |
| 333 | + | |
| 334 | + # Never send an empty users list: when the configured role filter matches | |
| 335 | + # no users, drop the filter and sync all users so connectivity is never | |
| 336 | + # broken by a role preference. (Root cause of the "Not Connected" + no | |
| 337 | + # Content Genius sync report.) | |
| 338 | + if (empty($users) && isset($user_query_args['role__in'])) { | |
| 339 | + unset($user_query_args['role__in']); | |
| 340 | + $users = $user_fetcher($user_query_args); | |
| 341 | + if (!is_array($users)) { | |
| 342 | + $users = array(); | |
| 343 | + } | |
| 344 | + } | |
| 345 | + | |
| 346 | + # Get the default user role from WordPress settings (fallback to administrator) | |
| 347 | + $default_role = get_option('default_role', 'administrator'); | |
| 348 | + | |
| 349 | + $new_users = array(); | |
| 350 | + $user_count = 1; | |
| 351 | + foreach ($users as $user) { | |
| 352 | + if ($user_count <= $users_sync_limit) { | |
| 353 | + $user_data = $userdata_fetcher($user->ID); | |
| 354 | + # Skip if user data is invalid | |
| 355 | + if (!$user_data || !is_object($user_data)) { | |
| 356 | + continue; | |
| 357 | + } | |
| 358 | + | |
| 359 | + # Get user role with proper safety checks | |
| 360 | + $user_role = $default_role; | |
| 361 | + if (is_array($user_data->roles) && !empty($user_data->roles)) { | |
| 362 | + $user_role = isset($user_data->roles[0]) ? $user_data->roles[0] : $default_role; | |
| 363 | + } | |
| 364 | + | |
| 365 | + # Prepare user data with proper sanitization | |
| 366 | + # Using data from optimized query (ID, user_login, user_email already fetched) | |
| 367 | + $new_users[] = array( | |
| 368 | + 'id' => absint($user->ID), | |
| 369 | + 'user_login' => isset($user->user_login) ? sanitize_user($user->user_login) : '', | |
| 370 | + 'user_email' => isset($user->user_email) ? sanitize_email($user->user_email) : '', | |
| 371 | + 'role' => sanitize_key($user_role), | |
| 372 | + ); | |
| 373 | + } | |
| 374 | + $user_count++; | |
| 375 | + } | |
| 376 | + | |
| 377 | + return $new_users; | |
| 378 | + } | |
| 379 | + | |
| 332 | 380 | public function post_categories() { |
| 333 | 381 | $categories = get_categories(array( |
| 334 | 382 | 'hide_empty' => false, |
| 335 | 383 | )); |
| @@ -381,9 +429,15 @@ | ||
| 381 | 429 | public function SyncWhiteLabelUserHttp() |
| 382 | 430 | { |
| 383 | 431 | $general_options = Metasync::get_option('general') ?? []; |
| 384 | 432 | |
| 385 | - if (!isset($general_options['apikey'], $general_options['searchatlas_api_key'])) { | |
| 433 | + # Decrypt the stored key; false => undecryptable (salts changed) → bail. | |
| 434 | + $api_key = Metasync::get_searchatlas_api_key(); | |
| 435 | + if ($api_key === false) { | |
| 436 | + $api_key = ''; | |
| 437 | + } | |
| 438 | + | |
| 439 | + if (!isset($general_options['apikey']) || $api_key === '') { | |
| 386 | 440 | return; |
| 387 | 441 | } |
| 388 | 442 | |
| 389 | 443 | # Use endpoint manager for dynamic environment support |
| @@ -394,9 +448,9 @@ | ||
| 394 | 448 | |
| 395 | 449 | delete_option(Metasync::option_name . '_whitelabel_user'); |
| 396 | 450 | |
| 397 | 451 | $headers = array( |
| 398 | - 'x-api-key'=>$general_options['searchatlas_api_key'] // this should be associative array not a array of string | |
| 452 | + 'x-api-key'=>$api_key // this should be associative array not a array of string | |
| 399 | 453 | ); |
| 400 | 454 | $args = array( |
| 401 | 455 | 'headers' => $headers, |
| 402 | 456 | # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests |
| @@ -406,9 +460,9 @@ | ||
| 406 | 460 | $response = wp_remote_get($url, $args); |
| 407 | 461 | |
| 408 | 462 | # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors |
| 409 | 463 | if (is_wp_error($response)) { |
| 410 | - error_log('MetaSync: White label user sync failed: ' . $response->get_error_message()); | |
| 464 | + error_log('MetaSync: White label user sync failed: ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 411 | 465 | return; |
| 412 | 466 | } |
| 413 | 467 | |
| 414 | 468 | $body = wp_remote_retrieve_body($response); |