PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
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 2.6.4 2.6.3 All 139 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.18, at customer-sync-requests/class-metasync-sync-requests.php

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