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
metasync / customer-sync-requests / class-metasync-sync-requests.php

class-metasync-sync-requests.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at customer-sync-requests/class-metasync-sync-requests.php

481 lines 22.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The header and footer code snippets functionality of the plugin.
5 *
6 *
7 * @link https://searchatlas.com
8 * @since 1.0.0
9 * @package Metasync
10 * @subpackage Metasync/customer-sync-requests
11 * @author Engineering Team <[email protected]>
12 */
13
14 // Abort if this file is accessed directly.
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 class Metasync_Sync_Requests
20 {
21 /**
22 * Safe wrapper around wp_remote_retrieve_response_code() for
23 * SyncCustomerParams() return values.
24 *
25 * SyncCustomerParams() returns a plain stdClass object when the request
26 * is throttled. Passing that object into wp_remote_retrieve_response_code()
27 * is a fatal on PHP 8 ("Cannot use object of type stdClass as array"),
28 * which kills AJAX/REST requests mid-flight with an empty response.
29 *
30 * @param array|WP_Error|object|false $response SyncCustomerParams() return value.
31 * @return int|string HTTP status code, or '' when not an HTTP response.
32 */
33 public static function get_response_code($response)
34 {
35 if (is_array($response) || is_wp_error($response)) {
36 return wp_remote_retrieve_response_code($response);
37 }
38 return '';
39 }
40
41 /**
42 * Data or Response received from HeartBeat API for admin area.
43 *
44 * @param string|null $token Legacy auth token (unused by the request itself).
45 * @param string $context 'manual' for the Settings "Sync Now" button,
46 * 'heartbeat' for JS heartbeat ticks, '' for
47 * system callers (settings-save verification,
48 * cron connectivity test, category CRUD, REST).
49 * Only 'manual' consumes/stamps the manual
50 * cooldown.
51 */
52 public function SyncCustomerParams($token = null, $context = '')
53 {
54 $categories_sync_limit = 1000;
55 $users_sync_limit = 1000;
56
57 # get the metasync options array
58 $metasync_options = Metasync::get_option();
59
60 # set the general option
61 $general_options = $metasync_options['general'] ?? [];
62
63 if (!isset($general_options['apikey'], $general_options['searchatlas_api_key'])) {
64 return;
65 }
66
67
68 # From Feature Issue #132
69 # We need to alter this url based on API key
70 # so let's fethc the api key first
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
79 #check that the api key is not empty
80
81 if ($api_key === ''){
82 return false;
83 }
84
85 # last hb request — read from dedicated throttle option so we are not
86 # consulting a stale copy of the main options blob.
87 $_throttle = Metasync::get_heartbeat_throttle();
88 $last_hb_request_time = $_throttle['last_heart_beat'] ?? 0;
89
90 # PR3: Throttle depends on state — burst (KEY_PENDING within 30 min) allows 30s; else 5 min
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
93 $heartbeat_state = $metasync_options['general']['heartbeat_state'] ?? '';
94 $state_changed_at = (int) ($metasync_options['general']['heartbeat_state_changed_at'] ?? 0);
95 $burst_window_end = $state_changed_at + (30 * 60); // 30 min cap
96 $in_burst_window = ($heartbeat_state === 'KEY_PENDING' && $burst_window_end > time());
97 $min_interval_sec = ($in_burst_window || $is_burst) ? 30 : (60 * 5);
98
99 if ($is_heartbeat) {
100 if (($last_hb_request_time + $min_interval_sec) > time()) {
101 $remaining_time = ($last_hb_request_time + $min_interval_sec) - time();
102 $remaining_minutes = max(1, ceil($remaining_time / 60));
103 return (object) [
104 'error' => 'throttled',
105 'message' => 'Please make another request after ' . $remaining_minutes . ' minute(s)',
106 'remaining_minutes' => $remaining_minutes,
107 'last_request_time' => $last_hb_request_time,
108 'throttled' => true
109 ];
110 }
111 Metasync::set_heartbeat_throttle(['last_heart_beat' => time()]);
112 } elseif ($context === 'manual') {
113 # Manual "Sync Now" path: track throttle via a dedicated option key so
114 # heartbeat ticks (which update last_heart_beat every ~15s) cannot keep
115 # the manual cooldown alive indefinitely.
116 # only the Settings "Sync Now" button takes this branch. System
117 # callers (API-key save verification, cron connectivity test, category
118 # CRUD, REST sync) must neither be rejected by the manual cooldown —
119 # a throttled object reads as a failed verification and reverts a
120 # freshly saved API key — nor stamp it, which would lock the button
121 # for 5 minutes after every settings save.
122 $last_manual_sync_time = (int) ($metasync_options['general']['last_manual_sync'] ?? 0);
123 $manual_min_interval_sec = 60 * 5; // 5 minutes
124 if (($last_manual_sync_time + $manual_min_interval_sec) > time()) {
125 $remaining_time = ($last_manual_sync_time + $manual_min_interval_sec) - time();
126 $remaining_minutes = max(1, ceil($remaining_time / 60));
127 # Return remaining_seconds (a duration) instead of an absolute expires_at
128 # so the client computes its own expiry from Date.now() — avoids
129 # server/client clock drift locking the user out (or in).
130 return (object) [
131 'error' => 'throttled',
132 'message' => 'Please make another request after ' . $remaining_minutes . ' minute(s)',
133 'remaining_minutes' => $remaining_minutes,
134 'remaining_seconds' => $remaining_time,
135 'manual_cooldown_seconds' => $manual_min_interval_sec,
136 'last_request_time' => $last_manual_sync_time,
137 'throttled' => true
138 ];
139 }
140 # Persist immediately so the cooldown is enforced even if the remote
141 # request below fails or times out.
142 $metasync_options['general']['last_manual_sync'] = time();
143 Metasync::set_option($metasync_options);
144 }
145
146
147 #the native api url - use endpoint manager for dynamic environment support
148 $ca_api_domain = class_exists('Metasync_Endpoint_Manager')
149 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
150 : Metasync::CA_API_DOMAIN;
151 $apiUrl = $ca_api_domain . '/api/wp-website-heartbeat/';
152
153 #check if the api key starts with pub
154 if(strpos($api_key, 'pub-') === 0){
155
156 #set the heart beat url to the new one
157 $api_domain = class_exists('Metasync_Endpoint_Manager')
158 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
159 : Metasync::API_DOMAIN;
160 $apiUrl = $api_domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/';
161 }
162
163 $new_categories = $this->post_categories();
164 $this->saveHeartBeatError('categories', 'The limit of categories is exceeded', $new_categories, $categories_sync_limit);
165
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);
171
172 $this->saveHeartBeatError('users', 'The limit of users is exceeded', $new_users, $users_sync_limit);
173 $current_permalink_structure = get_option('permalink_structure');
174 $current_rewrite_rules = get_option('rewrite_rules');
175
176 $payload = [
177 'url' => get_home_url(),
178 'api_key' => $general_options['apikey'],
179 'categories' => $new_categories,
180 'users' => $new_users,
181 'version'=>constant('METASYNC_VERSION'),
182 'permalink_structure'=>((($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '')?false:true),
183 'otto_pixel_uuid' => $general_options['otto_pixel_uuid'] ?? '',
184 ];
185
186 if (Metasync_Headless_Config::is_active()) {
187 $payload['frontend_domain'] = Metasync_Headless_Config::get_frontend_domain();
188 }
189
190 # append login auth token to payload
191 if(!empty($token)){
192 $payload['login_auth_token'] = $token;
193 }
194
195 $data = [
196 'body' => $payload,
197 'headers' => [
198 'x-api-key' => $api_key,
199
200 ],
201 # PERFORMANCE OPTIMIZATION: Add timeout for sync operations
202 'timeout' => 15,
203 ];
204
205 $response = wp_remote_post($apiUrl, $data);
206
207 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
208 if (is_wp_error($response)) {
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
210 $this->saveHeartBeatError('heartbeat', 'Connection error: ' . $response->get_error_message(), array(1), 0);
211 return;
212 }
213
214 $response_code = wp_remote_retrieve_response_code( $response );
215 $response_message = wp_remote_retrieve_response_message( $response );
216
217 if ( 200 != $response_code && ! empty( $response_message ) ) {
218 $this->saveHeartBeatError('heartbeat', $response_code . ": " . $response_message, array(1), 0);
219 return; //new WP_Error( $response_code, $response_message );
220 } elseif ( 200 != $response_code ) {
221 $this->saveHeartBeatError('heartbeat', $response_code . ': Unknown error occurred', array(1), 0);
222 return; //new WP_Error( $response_code, 'Unknown error occurred' );
223 } else {
224 # Track only the fields this sync owns so we can re-read the
225 # current blob and merge just our updates — preserving any
226 # concurrent settings writes that happened during the HTTP window.
227 $_sync_updates = [];
228
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.
233 $response_body = json_decode(wp_remote_retrieve_body($response), true);
234 $current_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
235 if (is_array($response_body) && !empty($response_body['otto_pixel_uuid'])) {
236 $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']);
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'])) {
240 # Domain clone: backend says local UUID is wrong for this domain
241 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
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
243 } elseif (empty($current_uuid)) {
244 # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat
245 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
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
247 }
248 }
249
250 # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility)
251 if (is_array($response_body) && (!empty($response_body['registered']) || !empty($response_body['heartbeat_confirmed']))) {
252 $_sync_updates['heartbeat_state'] = 'CONNECTED';
253 $_sync_updates['heartbeat_state_changed_at'] = time();
254 }
255
256 # Granular otto_config_status: record last successful heartbeat (ISO 8601 UTC).
257 # Throttle timestamp lives in a dedicated option key, written atomically
258 # so it never round-trips the main options blob.
259 Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]);
260
261 # Re-read the latest options blob immediately before the final write so
262 # any concurrent settings writes during the wp_remote_post window are
263 # preserved; only the fields owned by the sync are overlaid.
264 $_fresh = Metasync::get_option();
265 if (!isset($_fresh['general'])) {
266 $_fresh['general'] = [];
267 }
268 $_fresh['general'] = array_merge($_fresh['general'], $_sync_updates);
269 Metasync::set_option($_fresh);
270
271 return $response;
272 }
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
385 public function post_categories() {
386 $categories = get_categories(array(
387 'hide_empty' => false,
388 ));
389
390 $categories = array_map(function($category) {
391 return [
392 'id' => $category->term_id,
393 'name' => $category->name,
394 'parent' => $category->parent,
395 ];
396 }, $categories);
397
398 $hierarchy = $this->build_category_hierarchy($categories);
399
400 return $hierarchy;
401 }
402
403 public function build_category_hierarchy($categories, $parentId = 0) {
404 $result = [];
405 foreach ($categories as $category) {
406 if ($category['parent'] == $parentId) {
407 $children = $this->build_category_hierarchy($categories, $category['id']);
408 if ($children) {
409 $category['children'] = $children;
410 }
411 $result[] = $category;
412 }
413 }
414 return $result;
415 }
416
417 public function saveHeartBeatError($attribute, $description, $records, $limit)
418 {
419 $records_count = count($records);
420 if ($records_count > $limit) {
421 $HeartBeatDatabase = new Metasync_HeartBeat_Error_Monitor_Database();
422 $args = [
423 'attribute_name' => $attribute,
424 'object_count' => $records_count,
425 'error_description' => $description,
426 ];
427 $HeartBeatDatabase->add($args);
428 }
429 }
430
431 /**
432 * Data or Response received from HeartBeat API for admin area.
433 */
434 public function SyncWhiteLabelUserHttp()
435 {
436 $general_options = Metasync::get_option('general') ?? [];
437
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 === '') {
445 return;
446 }
447
448 # Use endpoint manager for dynamic environment support
449 $api_domain = class_exists('Metasync_Endpoint_Manager')
450 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
451 : Metasync::API_DOMAIN;
452 $url = $api_domain . "/api/customer/account/user/"; // the URL to request
453
454 delete_option(Metasync::option_name . '_whitelabel_user');
455
456 $headers = array(
457 'x-api-key'=>$api_key // this should be associative array not a array of string
458 );
459 $args = array(
460 'headers' => $headers,
461 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
462 'timeout' => 10,
463 );
464
465 $response = wp_remote_get($url, $args);
466
467 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
468 if (is_wp_error($response)) {
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
470 return;
471 }
472
473 $body = wp_remote_retrieve_body($response);
474 $result = json_decode($body, true);
475
476 if (is_array($result) && !empty($result['company_name'])) {
477 update_option(Metasync::option_name . '_whitelabel_user', $result['company_name']);
478 }
479 }
480 }
481