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

476 lines 21.7 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 $response_body = json_decode(wp_remote_retrieve_body($response), true);
231 $current_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
232 if (is_array($response_body) && !empty($response_body['otto_pixel_uuid'])) {
233 $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']);
234 if (!empty($response_body['uuid_mismatch'])) {
235 # Domain clone: backend says local UUID is wrong for this domain
236 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
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
238 } elseif (empty($current_uuid)) {
239 # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat
240 $_sync_updates['otto_pixel_uuid'] = $response_uuid;
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
242 }
243 }
244
245 # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility)
246 if (is_array($response_body) && (!empty($response_body['registered']) || !empty($response_body['heartbeat_confirmed']))) {
247 $_sync_updates['heartbeat_state'] = 'CONNECTED';
248 $_sync_updates['heartbeat_state_changed_at'] = time();
249 }
250
251 # Granular otto_config_status: record last successful heartbeat (ISO 8601 UTC).
252 # Throttle timestamp lives in a dedicated option key, written atomically
253 # so it never round-trips the main options blob.
254 Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]);
255
256 # Re-read the latest options blob immediately before the final write so
257 # any concurrent settings writes during the wp_remote_post window are
258 # preserved; only the fields owned by the sync are overlaid.
259 $_fresh = Metasync::get_option();
260 if (!isset($_fresh['general'])) {
261 $_fresh['general'] = [];
262 }
263 $_fresh['general'] = array_merge($_fresh['general'], $_sync_updates);
264 Metasync::set_option($_fresh);
265
266 return $response;
267 }
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
380 public function post_categories() {
381 $categories = get_categories(array(
382 'hide_empty' => false,
383 ));
384
385 $categories = array_map(function($category) {
386 return [
387 'id' => $category->term_id,
388 'name' => $category->name,
389 'parent' => $category->parent,
390 ];
391 }, $categories);
392
393 $hierarchy = $this->build_category_hierarchy($categories);
394
395 return $hierarchy;
396 }
397
398 public function build_category_hierarchy($categories, $parentId = 0) {
399 $result = [];
400 foreach ($categories as $category) {
401 if ($category['parent'] == $parentId) {
402 $children = $this->build_category_hierarchy($categories, $category['id']);
403 if ($children) {
404 $category['children'] = $children;
405 }
406 $result[] = $category;
407 }
408 }
409 return $result;
410 }
411
412 public function saveHeartBeatError($attribute, $description, $records, $limit)
413 {
414 $records_count = count($records);
415 if ($records_count > $limit) {
416 $HeartBeatDatabase = new Metasync_HeartBeat_Error_Monitor_Database();
417 $args = [
418 'attribute_name' => $attribute,
419 'object_count' => $records_count,
420 'error_description' => $description,
421 ];
422 $HeartBeatDatabase->add($args);
423 }
424 }
425
426 /**
427 * Data or Response received from HeartBeat API for admin area.
428 */
429 public function SyncWhiteLabelUserHttp()
430 {
431 $general_options = Metasync::get_option('general') ?? [];
432
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 === '') {
440 return;
441 }
442
443 # Use endpoint manager for dynamic environment support
444 $api_domain = class_exists('Metasync_Endpoint_Manager')
445 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
446 : Metasync::API_DOMAIN;
447 $url = $api_domain . "/api/customer/account/user/"; // the URL to request
448
449 delete_option(Metasync::option_name . '_whitelabel_user');
450
451 $headers = array(
452 'x-api-key'=>$api_key // this should be associative array not a array of string
453 );
454 $args = array(
455 'headers' => $headers,
456 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
457 'timeout' => 10,
458 );
459
460 $response = wp_remote_get($url, $args);
461
462 # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors
463 if (is_wp_error($response)) {
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
465 return;
466 }
467
468 $body = wp_remote_retrieve_body($response);
469 $result = json_decode($body, true);
470
471 if (is_array($result) && !empty($result['company_name'])) {
472 update_option(Metasync::option_name . '_whitelabel_user', $result['company_name']);
473 }
474 }
475 }
476