PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | customer-sync-requests/class-metasync-sync-requests.php +145 -91 2.6.12 → 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);