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

422 lines 18.3 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 (WP-426).
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 $api_key = $general_options['searchatlas_api_key'] ?? null;
73
74 #check that the api key is not empty
75
76 if ($api_key == null){
77 return false;
78 }
79
80 # last hb request — read from dedicated throttle option so we are not
81 # consulting a stale copy of the main options blob.
82 $_throttle = Metasync::get_heartbeat_throttle();
83 $last_hb_request_time = $_throttle['last_heart_beat'] ?? 0;
84
85 # 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']);
88 $heartbeat_state = $metasync_options['general']['heartbeat_state'] ?? '';
89 $state_changed_at = (int) ($metasync_options['general']['heartbeat_state_changed_at'] ?? 0);
90 $burst_window_end = $state_changed_at + (30 * 60); // 30 min cap
91 $in_burst_window = ($heartbeat_state === 'KEY_PENDING' && $burst_window_end > time());
92 $min_interval_sec = ($in_burst_window || $is_burst) ? 30 : (60 * 5);
93
94 if ($is_heartbeat) {
95 if (($last_hb_request_time + $min_interval_sec) > time()) {
96 $remaining_time = ($last_hb_request_time + $min_interval_sec) - time();
97 $remaining_minutes = max(1, ceil($remaining_time / 60));
98 return (object) [
99 'error' => 'throttled',
100 'message' => 'Please make another request after ' . $remaining_minutes . ' minute(s)',
101 'remaining_minutes' => $remaining_minutes,
102 'last_request_time' => $last_hb_request_time,
103 'throttled' => true
104 ];
105 }
106 Metasync::set_heartbeat_throttle(['last_heart_beat' => time()]);
107 } elseif ($context === 'manual') {
108 # Manual "Sync Now" path: track throttle via a dedicated option key so
109 # heartbeat ticks (which update last_heart_beat every ~15s) cannot keep
110 # the manual cooldown alive indefinitely.
111 # WP-426: only the Settings "Sync Now" button takes this branch. System
112 # callers (API-key save verification, cron connectivity test, category
113 # CRUD, REST sync) must neither be rejected by the manual cooldown —
114 # a throttled object reads as a failed verification and reverts a
115 # freshly saved API key — nor stamp it, which would lock the button
116 # for 5 minutes after every settings save.
117 $last_manual_sync_time = (int) ($metasync_options['general']['last_manual_sync'] ?? 0);
118 $manual_min_interval_sec = 60 * 5; // 5 minutes
119 if (($last_manual_sync_time + $manual_min_interval_sec) > time()) {
120 $remaining_time = ($last_manual_sync_time + $manual_min_interval_sec) - time();
121 $remaining_minutes = max(1, ceil($remaining_time / 60));
122 # Return remaining_seconds (a duration) instead of an absolute expires_at
123 # so the client computes its own expiry from Date.now() — avoids
124 # server/client clock drift locking the user out (or in).
125 return (object) [
126 'error' => 'throttled',
127 'message' => 'Please make another request after ' . $remaining_minutes . ' minute(s)',
128 'remaining_minutes' => $remaining_minutes,
129 'remaining_seconds' => $remaining_time,
130 'manual_cooldown_seconds' => $manual_min_interval_sec,
131 'last_request_time' => $last_manual_sync_time,
132 'throttled' => true
133 ];
134 }
135 # Persist immediately so the cooldown is enforced even if the remote
136 # request below fails or times out.
137 $metasync_options['general']['last_manual_sync'] = time();
138 Metasync::set_option($metasync_options);
139 }
140
141
142 #the native api url - use endpoint manager for dynamic environment support
143 $ca_api_domain = class_exists('Metasync_Endpoint_Manager')
144 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
145 : Metasync::CA_API_DOMAIN;
146 $apiUrl = $ca_api_domain . '/api/wp-website-heartbeat/';
147
148 #check if the api key starts with pub
149 if(strpos($api_key, 'pub-') === 0){
150
151 #set the heart beat url to the new one
152 $api_domain = class_exists('Metasync_Endpoint_Manager')
153 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
154 : Metasync::API_DOMAIN;
155 $apiUrl = $api_domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/';
156 }
157
158 $new_categories = $this->post_categories();
159 $this->saveHeartBeatError('categories', 'The limit of categories is exceeded', $new_categories, $categories_sync_limit);
160
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');
208
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 $this->saveHeartBeatError('users', 'The limit of users is exceeded', $new_users, $users_sync_limit);
240 $current_permalink_structure = get_option('permalink_structure');
241 $current_rewrite_rules = get_option('rewrite_rules');
242
243 $payload = [
244 'url' => get_home_url(),
245 'api_key' => $general_options['apikey'],
246 'categories' => $new_categories,
247 'users' => $new_users,
248 'version'=>constant('METASYNC_VERSION'),
249 'permalink_structure'=>((($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '')?false:true),
250 'otto_pixel_uuid' => $general_options['otto_pixel_uuid'] ?? '',
251 ];
252
253 # append login auth token to payload
254 if(!empty($token)){
255 $payload['login_auth_token'] = $token;
256 }
257
258 $data = [
259 'body' => $payload,
260 'headers' => [
261 'x-api-key' => $general_options['searchatlas_api_key'],
262
263 ],
264 # PERFORMANCE OPTIMIZATION: Add timeout for sync operations
265 'timeout' => 15,
266 ];
267
268 $response = wp_remote_post($apiUrl, $data);
269
270 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
271 if (is_wp_error($response)) {
272 error_log('MetaSync: Heartbeat sync failed: ' . $response->get_error_message());
273 $this->saveHeartBeatError('heartbeat', 'Connection error: ' . $response->get_error_message(), array(1), 0);
274 return;
275 }
276
277 $response_code = wp_remote_retrieve_response_code( $response );
278 $response_message = wp_remote_retrieve_response_message( $response );
279
280 if ( 200 != $response_code && ! empty( $response_message ) ) {
281 $this->saveHeartBeatError('heartbeat', $response_code . ": " . $response_message, array(1), 0);
282 return; //new WP_Error( $response_code, $response_message );
283 } elseif ( 200 != $response_code ) {
284 $this->saveHeartBeatError('heartbeat', $response_code . ': Unknown error occurred', array(1), 0);
285 return; //new WP_Error( $response_code, 'Unknown error occurred' );
286 } else {
287 # Track only the fields this sync owns so we can re-read the
288 # current blob and merge just our updates — preserving any
289 # concurrent settings writes that happened during the HTTP window.
290 $_sync_updates = [];
291
292 # PR2: Parse heartbeat response for UUID self-healing and clone detection
293 $response_body = json_decode(wp_remote_retrieve_body($response), true);
294 $current_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
295 if (is_array($response_body) && !empty($response_body['otto_pixel_uuid'])) {
296 $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']);
297 if (!empty($response_body['uuid_mismatch'])) {
298 # Domain clone: backend says local UUID is wrong for this domain
299 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
300 error_log('MetaSync: UUID corrected from ' . $current_uuid . ' to ' . $response_uuid . ' (domain clone detected)');
301 } elseif (empty($current_uuid)) {
302 # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat
303 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
304 error_log('MetaSync: UUID set from heartbeat response (self-heal after SSO callback missed)');
305 }
306 }
307
308 # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility)
309 if (is_array($response_body) && (!empty($response_body['registered']) || !empty($response_body['heartbeat_confirmed']))) {
310 $_sync_updates['heartbeat_state'] = 'CONNECTED';
311 $_sync_updates['heartbeat_state_changed_at'] = time();
312 }
313
314 # Granular otto_config_status: record last successful heartbeat (ISO 8601 UTC).
315 # Throttle timestamp lives in a dedicated option key, written atomically
316 # so it never round-trips the main options blob.
317 Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]);
318
319 # Re-read the latest options blob immediately before the final write so
320 # any concurrent settings writes during the wp_remote_post window are
321 # preserved; only the fields owned by the sync are overlaid.
322 $_fresh = Metasync::get_option();
323 if (!isset($_fresh['general'])) {
324 $_fresh['general'] = [];
325 }
326 $_fresh['general'] = array_merge($_fresh['general'], $_sync_updates);
327 Metasync::set_option($_fresh);
328
329 return $response;
330 }
331 }
332 public function post_categories() {
333 $categories = get_categories(array(
334 'hide_empty' => false,
335 ));
336
337 $categories = array_map(function($category) {
338 return [
339 'id' => $category->term_id,
340 'name' => $category->name,
341 'parent' => $category->parent,
342 ];
343 }, $categories);
344
345 $hierarchy = $this->build_category_hierarchy($categories);
346
347 return $hierarchy;
348 }
349
350 public function build_category_hierarchy($categories, $parentId = 0) {
351 $result = [];
352 foreach ($categories as $category) {
353 if ($category['parent'] == $parentId) {
354 $children = $this->build_category_hierarchy($categories, $category['id']);
355 if ($children) {
356 $category['children'] = $children;
357 }
358 $result[] = $category;
359 }
360 }
361 return $result;
362 }
363
364 public function saveHeartBeatError($attribute, $description, $records, $limit)
365 {
366 $records_count = count($records);
367 if ($records_count > $limit) {
368 $HeartBeatDatabase = new Metasync_HeartBeat_Error_Monitor_Database();
369 $args = [
370 'attribute_name' => $attribute,
371 'object_count' => $records_count,
372 'error_description' => $description,
373 ];
374 $HeartBeatDatabase->add($args);
375 }
376 }
377
378 /**
379 * Data or Response received from HeartBeat API for admin area.
380 */
381 public function SyncWhiteLabelUserHttp()
382 {
383 $general_options = Metasync::get_option('general') ?? [];
384
385 if (!isset($general_options['apikey'], $general_options['searchatlas_api_key'])) {
386 return;
387 }
388
389 # Use endpoint manager for dynamic environment support
390 $api_domain = class_exists('Metasync_Endpoint_Manager')
391 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
392 : Metasync::API_DOMAIN;
393 $url = $api_domain . "/api/customer/account/user/"; // the URL to request
394
395 delete_option(Metasync::option_name . '_whitelabel_user');
396
397 $headers = array(
398 'x-api-key'=>$general_options['searchatlas_api_key'] // this should be associative array not a array of string
399 );
400 $args = array(
401 'headers' => $headers,
402 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
403 'timeout' => 10,
404 );
405
406 $response = wp_remote_get($url, $args);
407
408 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
409 if (is_wp_error($response)) {
410 error_log('MetaSync: White label user sync failed: ' . $response->get_error_message());
411 return;
412 }
413
414 $body = wp_remote_retrieve_body($response);
415 $result = json_decode($body, true);
416
417 if (is_array($result) && !empty($result['company_name'])) {
418 update_option(Metasync::option_name . '_whitelabel_user', $result['company_name']);
419 }
420 }
421 }
422