PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | customer-sync-requests/class-metasync-sync-requests.php +151 -92 2.6.12 → 2.7.2 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
@@ -289,20 +226,25 @@
289 226 # concurrent settings writes that happened during the HTTP window.
290 227 $_sync_updates = [];
291 228
292 229 # PR2: Parse heartbeat response for UUID self-healing and clone detection
230 + # The UUID is only accepted when it is a real UUID: the
231 + # heartbeat response is remote input, and usernames/arbitrary text
232 + # landing in otto_pixel_uuid break OTTO SSR authentication.
293 233 $response_body = json_decode(wp_remote_retrieve_body($response), true);
294 234 $current_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
295 235 if (is_array($response_body) && !empty($response_body['otto_pixel_uuid'])) {
296 236 $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']);
297 - if (!empty($response_body['uuid_mismatch'])) {
237 + if (!Metasync::is_valid_uuid($response_uuid)) {
238 + error_log('MetaSync: ignoring invalid otto_pixel_uuid from heartbeat response (not a UUID)');
239 + } elseif (!empty($response_body['uuid_mismatch'])) {
298 240 # Domain clone: backend says local UUID is wrong for this domain
299 241 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
300 - error_log('MetaSync: UUID corrected from ' . $current_uuid . ' to ' . $response_uuid . ' (domain clone detected)');
242 + 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 243 } elseif (empty($current_uuid)) {
302 244 # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat
303 245 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
304 - error_log('MetaSync: UUID set from heartbeat response (self-heal after SSO callback missed)');
246 + 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 247 }
306 248 }
307 249
308 250 # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility)
@@ -328,8 +270,119 @@
328 270
329 271 return $response;
330 272 }
331 273 }
274 + /**
275 + * Build the users payload for the heartbeat/sync request.
276 + *
277 + * Honors the Content Genius role filter (content_genius_sync_roles). The
278 + * critical guarantee: the returned list is never left empty because the
279 + * role filter matched nobody. The Search Atlas heartbeat endpoint rejects
280 + * an empty `users` list with HTTP 400 ("users: This field is required."),
281 + * which fails the ENTIRE sync — flipping the site to "Not Connected" and
282 + * blocking Content Genius. So when a role filter is configured but matches
283 + * zero users on the site, we fall back to all users (the same set sent when
284 + * no filter is configured) so a role preference can never sever the
285 + * connection. (If the site genuinely has no users at all, the list can
286 + * still be empty — but that cannot happen on a real WordPress install.)
287 + *
288 + * @param array $general_options The plugin 'general' options array.
289 + * @param callable|null $user_fetcher Fetches users for a WP_User_Query
290 + * args array; defaults to get_users().
291 + * Injected in tests.
292 + * @param callable|null $userdata_fetcher Fetches a WP_User by ID; defaults to
293 + * get_userdata(). Injected in tests.
294 + * @return array<int,array{id:int,user_login:string,user_email:string,role:string}>
295 + */
296 + public function build_users_payload($general_options, ?callable $user_fetcher = null, ?callable $userdata_fetcher = null)
297 + {
298 + $users_sync_limit = 1000;
299 + $user_fetcher = $user_fetcher ?? 'get_users';
300 + $userdata_fetcher = $userdata_fetcher ?? 'get_userdata';
301 +
302 + # Get selected roles for Content Genius sync with safety checks
303 + $selected_roles = isset($general_options['content_genius_sync_roles']) && is_array($general_options['content_genius_sync_roles'])
304 + ? $general_options['content_genius_sync_roles']
305 + : array();
306 +
307 + # If it's a string (single role from old version), convert to array
308 + if (!is_array($selected_roles)) {
309 + $selected_roles = !empty($selected_roles) ? array($selected_roles) : array();
310 + }
311 +
312 + # Sanitize role values to prevent injection
313 + $selected_roles = array_map('sanitize_key', $selected_roles);
314 +
315 + # Prepare optimized user query arguments - only fetch required fields
316 + $user_query_args = array(
317 + 'number' => $users_sync_limit,
318 + 'fields' => array('ID', 'user_login', 'user_email'), // Only fetch needed fields for performance
319 + 'orderby' => 'ID',
320 + 'order' => 'ASC',
321 + );
322 +
323 + # If specific roles are selected and "all" is not selected, filter by those roles
324 + if (!empty($selected_roles) && !in_array('all', $selected_roles, true)) {
325 + $valid_roles = array_filter($selected_roles, function ($role) {
326 + return !empty($role) && $role !== 'all';
327 + });
328 + if (!empty($valid_roles)) {
329 + $user_query_args['role__in'] = $valid_roles;
330 + }
331 + }
332 +
333 + # Fetch users based on the selected roles with error handling
334 + $users = $user_fetcher($user_query_args);
335 + if (!is_array($users)) {
336 + $users = array();
337 + }
338 +
339 + # Never send an empty users list: when the configured role filter matches
340 + # no users, drop the filter and sync all users so connectivity is never
341 + # broken by a role preference. (Root cause of the "Not Connected" + no
342 + # Content Genius sync report.)
343 + if (empty($users) && isset($user_query_args['role__in'])) {
344 + unset($user_query_args['role__in']);
345 + $users = $user_fetcher($user_query_args);
346 + if (!is_array($users)) {
347 + $users = array();
348 + }
349 + }
350 +
351 + # Get the default user role from WordPress settings (fallback to administrator)
352 + $default_role = get_option('default_role', 'administrator');
353 +
354 + $new_users = array();
355 + $user_count = 1;
356 + foreach ($users as $user) {
357 + if ($user_count <= $users_sync_limit) {
358 + $user_data = $userdata_fetcher($user->ID);
359 + # Skip if user data is invalid
360 + if (!$user_data || !is_object($user_data)) {
361 + continue;
362 + }
363 +
364 + # Get user role with proper safety checks
365 + $user_role = $default_role;
366 + if (is_array($user_data->roles) && !empty($user_data->roles)) {
367 + $user_role = isset($user_data->roles[0]) ? $user_data->roles[0] : $default_role;
368 + }
369 +
370 + # Prepare user data with proper sanitization
371 + # Using data from optimized query (ID, user_login, user_email already fetched)
372 + $new_users[] = array(
373 + 'id' => absint($user->ID),
374 + 'user_login' => isset($user->user_login) ? sanitize_user($user->user_login) : '',
375 + 'user_email' => isset($user->user_email) ? sanitize_email($user->user_email) : '',
376 + 'role' => sanitize_key($user_role),
377 + );
378 + }
379 + $user_count++;
380 + }
381 +
382 + return $new_users;
383 + }
384 +
332 385 public function post_categories() {
333 386 $categories = get_categories(array(
334 387 'hide_empty' => false,
335 388 ));
@@ -381,9 +434,15 @@
381 434 public function SyncWhiteLabelUserHttp()
382 435 {
383 436 $general_options = Metasync::get_option('general') ?? [];
384 437
385 - if (!isset($general_options['apikey'], $general_options['searchatlas_api_key'])) {
438 + # Decrypt the stored key; false => undecryptable (salts changed) → bail.
439 + $api_key = Metasync::get_searchatlas_api_key();
440 + if ($api_key === false) {
441 + $api_key = '';
442 + }
443 +
444 + if (!isset($general_options['apikey']) || $api_key === '') {
386 445 return;
387 446 }
388 447
389 448 # Use endpoint manager for dynamic environment support
@@ -394,9 +453,9 @@
394 453
395 454 delete_option(Metasync::option_name . '_whitelabel_user');
396 455
397 456 $headers = array(
398 - 'x-api-key'=>$general_options['searchatlas_api_key'] // this should be associative array not a array of string
457 + 'x-api-key'=>$api_key // this should be associative array not a array of string
399 458 );
400 459 $args = array(
401 460 'headers' => $headers,
402 461 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
@@ -406,9 +465,9 @@
406 465 $response = wp_remote_get($url, $args);
407 466
408 467 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
409 468 if (is_wp_error($response)) {
410 - error_log('MetaSync: White label user sync failed: ' . $response->get_error_message());
469 + 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 470 return;
412 471 }
413 472
414 473 $body = wp_remote_retrieve_body($response);