PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
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.6.25, at customer-sync-requests/class-metasync-sync-requests.php

472 lines 20.8 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);
92 $is_burst = !empty($_POST['is_burst']);
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 # append login auth token to payload
187 if(!empty($token)){
188 $payload['login_auth_token'] = $token;
189 }
190
191 $data = [
192 'body' => $payload,
193 'headers' => [
194 'x-api-key' => $api_key,
195
196 ],
197 # PERFORMANCE OPTIMIZATION: Add timeout for sync operations
198 'timeout' => 15,
199 ];
200
201 $response = wp_remote_post($apiUrl, $data);
202
203 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
204 if (is_wp_error($response)) {
205 error_log('MetaSync: Heartbeat sync failed: ' . $response->get_error_message());
206 $this->saveHeartBeatError('heartbeat', 'Connection error: ' . $response->get_error_message(), array(1), 0);
207 return;
208 }
209
210 $response_code = wp_remote_retrieve_response_code( $response );
211 $response_message = wp_remote_retrieve_response_message( $response );
212
213 if ( 200 != $response_code && ! empty( $response_message ) ) {
214 $this->saveHeartBeatError('heartbeat', $response_code . ": " . $response_message, array(1), 0);
215 return; //new WP_Error( $response_code, $response_message );
216 } elseif ( 200 != $response_code ) {
217 $this->saveHeartBeatError('heartbeat', $response_code . ': Unknown error occurred', array(1), 0);
218 return; //new WP_Error( $response_code, 'Unknown error occurred' );
219 } else {
220 # Track only the fields this sync owns so we can re-read the
221 # current blob and merge just our updates — preserving any
222 # concurrent settings writes that happened during the HTTP window.
223 $_sync_updates = [];
224
225 # PR2: Parse heartbeat response for UUID self-healing and clone detection
226 $response_body = json_decode(wp_remote_retrieve_body($response), true);
227 $current_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
228 if (is_array($response_body) && !empty($response_body['otto_pixel_uuid'])) {
229 $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']);
230 if (!empty($response_body['uuid_mismatch'])) {
231 # Domain clone: backend says local UUID is wrong for this domain
232 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
233 error_log('MetaSync: UUID corrected from ' . $current_uuid . ' to ' . $response_uuid . ' (domain clone detected)');
234 } elseif (empty($current_uuid)) {
235 # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat
236 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
237 error_log('MetaSync: UUID set from heartbeat response (self-heal after SSO callback missed)');
238 }
239 }
240
241 # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility)
242 if (is_array($response_body) && (!empty($response_body['registered']) || !empty($response_body['heartbeat_confirmed']))) {
243 $_sync_updates['heartbeat_state'] = 'CONNECTED';
244 $_sync_updates['heartbeat_state_changed_at'] = time();
245 }
246
247 # Granular otto_config_status: record last successful heartbeat (ISO 8601 UTC).
248 # Throttle timestamp lives in a dedicated option key, written atomically
249 # so it never round-trips the main options blob.
250 Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]);
251
252 # Re-read the latest options blob immediately before the final write so
253 # any concurrent settings writes during the wp_remote_post window are
254 # preserved; only the fields owned by the sync are overlaid.
255 $_fresh = Metasync::get_option();
256 if (!isset($_fresh['general'])) {
257 $_fresh['general'] = [];
258 }
259 $_fresh['general'] = array_merge($_fresh['general'], $_sync_updates);
260 Metasync::set_option($_fresh);
261
262 return $response;
263 }
264 }
265 /**
266 * Build the users payload for the heartbeat/sync request.
267 *
268 * Honors the Content Genius role filter (content_genius_sync_roles). The
269 * critical guarantee: the returned list is never left empty because the
270 * role filter matched nobody. The Search Atlas heartbeat endpoint rejects
271 * an empty `users` list with HTTP 400 ("users: This field is required."),
272 * which fails the ENTIRE sync — flipping the site to "Not Connected" and
273 * blocking Content Genius. So when a role filter is configured but matches
274 * zero users on the site, we fall back to all users (the same set sent when
275 * no filter is configured) so a role preference can never sever the
276 * connection. (If the site genuinely has no users at all, the list can
277 * still be empty — but that cannot happen on a real WordPress install.)
278 *
279 * @param array $general_options The plugin 'general' options array.
280 * @param callable|null $user_fetcher Fetches users for a WP_User_Query
281 * args array; defaults to get_users().
282 * Injected in tests.
283 * @param callable|null $userdata_fetcher Fetches a WP_User by ID; defaults to
284 * get_userdata(). Injected in tests.
285 * @return array<int,array{id:int,user_login:string,user_email:string,role:string}>
286 */
287 public function build_users_payload($general_options, ?callable $user_fetcher = null, ?callable $userdata_fetcher = null)
288 {
289 $users_sync_limit = 1000;
290 $user_fetcher = $user_fetcher ?? 'get_users';
291 $userdata_fetcher = $userdata_fetcher ?? 'get_userdata';
292
293 # Get selected roles for Content Genius sync with safety checks
294 $selected_roles = isset($general_options['content_genius_sync_roles']) && is_array($general_options['content_genius_sync_roles'])
295 ? $general_options['content_genius_sync_roles']
296 : array();
297
298 # If it's a string (single role from old version), convert to array
299 if (!is_array($selected_roles)) {
300 $selected_roles = !empty($selected_roles) ? array($selected_roles) : array();
301 }
302
303 # Sanitize role values to prevent injection
304 $selected_roles = array_map('sanitize_key', $selected_roles);
305
306 # Prepare optimized user query arguments - only fetch required fields
307 $user_query_args = array(
308 'number' => $users_sync_limit,
309 'fields' => array('ID', 'user_login', 'user_email'), // Only fetch needed fields for performance
310 'orderby' => 'ID',
311 'order' => 'ASC',
312 );
313
314 # If specific roles are selected and "all" is not selected, filter by those roles
315 if (!empty($selected_roles) && !in_array('all', $selected_roles, true)) {
316 $valid_roles = array_filter($selected_roles, function ($role) {
317 return !empty($role) && $role !== 'all';
318 });
319 if (!empty($valid_roles)) {
320 $user_query_args['role__in'] = $valid_roles;
321 }
322 }
323
324 # Fetch users based on the selected roles with error handling
325 $users = $user_fetcher($user_query_args);
326 if (!is_array($users)) {
327 $users = array();
328 }
329
330 # Never send an empty users list: when the configured role filter matches
331 # no users, drop the filter and sync all users so connectivity is never
332 # broken by a role preference. (Root cause of the "Not Connected" + no
333 # Content Genius sync report.)
334 if (empty($users) && isset($user_query_args['role__in'])) {
335 unset($user_query_args['role__in']);
336 $users = $user_fetcher($user_query_args);
337 if (!is_array($users)) {
338 $users = array();
339 }
340 }
341
342 # Get the default user role from WordPress settings (fallback to administrator)
343 $default_role = get_option('default_role', 'administrator');
344
345 $new_users = array();
346 $user_count = 1;
347 foreach ($users as $user) {
348 if ($user_count <= $users_sync_limit) {
349 $user_data = $userdata_fetcher($user->ID);
350 # Skip if user data is invalid
351 if (!$user_data || !is_object($user_data)) {
352 continue;
353 }
354
355 # Get user role with proper safety checks
356 $user_role = $default_role;
357 if (is_array($user_data->roles) && !empty($user_data->roles)) {
358 $user_role = isset($user_data->roles[0]) ? $user_data->roles[0] : $default_role;
359 }
360
361 # Prepare user data with proper sanitization
362 # Using data from optimized query (ID, user_login, user_email already fetched)
363 $new_users[] = array(
364 'id' => absint($user->ID),
365 'user_login' => isset($user->user_login) ? sanitize_user($user->user_login) : '',
366 'user_email' => isset($user->user_email) ? sanitize_email($user->user_email) : '',
367 'role' => sanitize_key($user_role),
368 );
369 }
370 $user_count++;
371 }
372
373 return $new_users;
374 }
375
376 public function post_categories() {
377 $categories = get_categories(array(
378 'hide_empty' => false,
379 ));
380
381 $categories = array_map(function($category) {
382 return [
383 'id' => $category->term_id,
384 'name' => $category->name,
385 'parent' => $category->parent,
386 ];
387 }, $categories);
388
389 $hierarchy = $this->build_category_hierarchy($categories);
390
391 return $hierarchy;
392 }
393
394 public function build_category_hierarchy($categories, $parentId = 0) {
395 $result = [];
396 foreach ($categories as $category) {
397 if ($category['parent'] == $parentId) {
398 $children = $this->build_category_hierarchy($categories, $category['id']);
399 if ($children) {
400 $category['children'] = $children;
401 }
402 $result[] = $category;
403 }
404 }
405 return $result;
406 }
407
408 public function saveHeartBeatError($attribute, $description, $records, $limit)
409 {
410 $records_count = count($records);
411 if ($records_count > $limit) {
412 $HeartBeatDatabase = new Metasync_HeartBeat_Error_Monitor_Database();
413 $args = [
414 'attribute_name' => $attribute,
415 'object_count' => $records_count,
416 'error_description' => $description,
417 ];
418 $HeartBeatDatabase->add($args);
419 }
420 }
421
422 /**
423 * Data or Response received from HeartBeat API for admin area.
424 */
425 public function SyncWhiteLabelUserHttp()
426 {
427 $general_options = Metasync::get_option('general') ?? [];
428
429 # Decrypt the stored key; false => undecryptable (salts changed) → bail.
430 $api_key = Metasync::get_searchatlas_api_key();
431 if ($api_key === false) {
432 $api_key = '';
433 }
434
435 if (!isset($general_options['apikey']) || $api_key === '') {
436 return;
437 }
438
439 # Use endpoint manager for dynamic environment support
440 $api_domain = class_exists('Metasync_Endpoint_Manager')
441 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
442 : Metasync::API_DOMAIN;
443 $url = $api_domain . "/api/customer/account/user/"; // the URL to request
444
445 delete_option(Metasync::option_name . '_whitelabel_user');
446
447 $headers = array(
448 'x-api-key'=>$api_key // this should be associative array not a array of string
449 );
450 $args = array(
451 'headers' => $headers,
452 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
453 'timeout' => 10,
454 );
455
456 $response = wp_remote_get($url, $args);
457
458 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
459 if (is_wp_error($response)) {
460 error_log('MetaSync: White label user sync failed: ' . $response->get_error_message());
461 return;
462 }
463
464 $body = wp_remote_retrieve_body($response);
465 $result = json_decode($body, true);
466
467 if (is_array($result) && !empty($result['company_name'])) {
468 update_option(Metasync::option_name . '_whitelabel_user', $result['company_name']);
469 }
470 }
471 }
472