*/ // Abort if this file is accessed directly. if (!defined('ABSPATH')) { exit; } class Metasync_Sync_Requests { /** * Safe wrapper around wp_remote_retrieve_response_code() for * SyncCustomerParams() return values. * * SyncCustomerParams() returns a plain stdClass object when the request * is throttled. Passing that object into wp_remote_retrieve_response_code() * is a fatal on PHP 8 ("Cannot use object of type stdClass as array"), * which kills AJAX/REST requests mid-flight with an empty response. * * @param array|WP_Error|object|false $response SyncCustomerParams() return value. * @return int|string HTTP status code, or '' when not an HTTP response. */ public static function get_response_code($response) { if (is_array($response) || is_wp_error($response)) { return wp_remote_retrieve_response_code($response); } return ''; } /** * Data or Response received from HeartBeat API for admin area. * * @param string|null $token Legacy auth token (unused by the request itself). * @param string $context 'manual' for the Settings "Sync Now" button, * 'heartbeat' for JS heartbeat ticks, '' for * system callers (settings-save verification, * cron connectivity test, category CRUD, REST). * Only 'manual' consumes/stamps the manual * cooldown. */ public function SyncCustomerParams($token = null, $context = '') { $categories_sync_limit = 1000; $users_sync_limit = 1000; # get the metasync options array $metasync_options = Metasync::get_option(); # set the general option $general_options = $metasync_options['general'] ?? []; if (!isset($general_options['apikey'], $general_options['searchatlas_api_key'])) { return; } # From Feature Issue #132 # We need to alter this url based on API key # so let's fethc the api key first # Decrypt the stored key for use as the x-api-key value. false => the # stored key could not be decrypted (salts changed); treat as no key. $api_key = Metasync::get_searchatlas_api_key(); if ($api_key === false) { $api_key = ''; } #check that the api key is not empty if ($api_key === ''){ return false; } # last hb request — read from dedicated throttle option so we are not # consulting a stale copy of the main options blob. $_throttle = Metasync::get_heartbeat_throttle(); $last_hb_request_time = $_throttle['last_heart_beat'] ?? 0; # PR3: Throttle depends on state — burst (KEY_PENDING within 30 min) allows 30s; else 5 min $is_heartbeat = ($context === 'heartbeat') || filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN); $is_burst = !empty($_POST['is_burst']); $heartbeat_state = $metasync_options['general']['heartbeat_state'] ?? ''; $state_changed_at = (int) ($metasync_options['general']['heartbeat_state_changed_at'] ?? 0); $burst_window_end = $state_changed_at + (30 * 60); // 30 min cap $in_burst_window = ($heartbeat_state === 'KEY_PENDING' && $burst_window_end > time()); $min_interval_sec = ($in_burst_window || $is_burst) ? 30 : (60 * 5); if ($is_heartbeat) { if (($last_hb_request_time + $min_interval_sec) > time()) { $remaining_time = ($last_hb_request_time + $min_interval_sec) - time(); $remaining_minutes = max(1, ceil($remaining_time / 60)); return (object) [ 'error' => 'throttled', 'message' => 'Please make another request after ' . $remaining_minutes . ' minute(s)', 'remaining_minutes' => $remaining_minutes, 'last_request_time' => $last_hb_request_time, 'throttled' => true ]; } Metasync::set_heartbeat_throttle(['last_heart_beat' => time()]); } elseif ($context === 'manual') { # Manual "Sync Now" path: track throttle via a dedicated option key so # heartbeat ticks (which update last_heart_beat every ~15s) cannot keep # the manual cooldown alive indefinitely. # only the Settings "Sync Now" button takes this branch. System # callers (API-key save verification, cron connectivity test, category # CRUD, REST sync) must neither be rejected by the manual cooldown — # a throttled object reads as a failed verification and reverts a # freshly saved API key — nor stamp it, which would lock the button # for 5 minutes after every settings save. $last_manual_sync_time = (int) ($metasync_options['general']['last_manual_sync'] ?? 0); $manual_min_interval_sec = 60 * 5; // 5 minutes if (($last_manual_sync_time + $manual_min_interval_sec) > time()) { $remaining_time = ($last_manual_sync_time + $manual_min_interval_sec) - time(); $remaining_minutes = max(1, ceil($remaining_time / 60)); # Return remaining_seconds (a duration) instead of an absolute expires_at # so the client computes its own expiry from Date.now() — avoids # server/client clock drift locking the user out (or in). return (object) [ 'error' => 'throttled', 'message' => 'Please make another request after ' . $remaining_minutes . ' minute(s)', 'remaining_minutes' => $remaining_minutes, 'remaining_seconds' => $remaining_time, 'manual_cooldown_seconds' => $manual_min_interval_sec, 'last_request_time' => $last_manual_sync_time, 'throttled' => true ]; } # Persist immediately so the cooldown is enforced even if the remote # request below fails or times out. $metasync_options['general']['last_manual_sync'] = time(); Metasync::set_option($metasync_options); } #the native api url - use endpoint manager for dynamic environment support $ca_api_domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN') : Metasync::CA_API_DOMAIN; $apiUrl = $ca_api_domain . '/api/wp-website-heartbeat/'; #check if the api key starts with pub if(strpos($api_key, 'pub-') === 0){ #set the heart beat url to the new one $api_domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN') : Metasync::API_DOMAIN; $apiUrl = $api_domain . '/api/publisher/one-click-publishing/wp-website-heartbeat/'; } $new_categories = $this->post_categories(); $this->saveHeartBeatError('categories', 'The limit of categories is exceeded', $new_categories, $categories_sync_limit); # $users = get_users(); # $new_users = []; # Get selected roles for Content Genius sync with safety checks $selected_roles = isset($general_options['content_genius_sync_roles']) && is_array($general_options['content_genius_sync_roles']) ? $general_options['content_genius_sync_roles'] : array(); # If it's a string (single role from old version), convert to array if (!is_array($selected_roles)) { $selected_roles = !empty($selected_roles) ? array($selected_roles) : array(); } # Sanitize role values to prevent injection $selected_roles = array_map('sanitize_key', $selected_roles); # Prepare optimized user query arguments - only fetch required fields $user_query_args = array( 'number' => $users_sync_limit, 'fields' => array('ID', 'user_login', 'user_email'), // Only fetch needed fields for performance 'orderby' => 'ID', 'order' => 'ASC' ); # If specific roles are selected and "all" is not selected, filter by those roles if (!empty($selected_roles) && !in_array('all', $selected_roles, true)) { # Only add role filter if we have valid roles $valid_roles = array_filter($selected_roles, function($role) { return !empty($role) && $role !== 'all'; }); if (!empty($valid_roles)) { $user_query_args['role__in'] = $valid_roles; } } # Fetch users based on the selected roles with error handling $users = get_users($user_query_args); # Safety check: ensure $users is an array if (!is_array($users)) { $users = array(); } $new_users = array(); $user_count = 1; # Get the default user role from WordPress settings # $default_role = get_option('default_role'); # Get the default user role from WordPress settings (fallback to administrator) $default_role = get_option('default_role', 'administrator'); foreach ($users as $user) { if ($user_count <= $users_sync_limit) { $user_data = get_userdata($user->ID); # Skip if user data is invalid if (!$user_data || !is_object($user_data)) { continue; } # Get user role with proper safety checks $user_role = $default_role; if (is_array($user_data->roles) && !empty($user_data->roles)) { $user_role = isset($user_data->roles[0]) ? $user_data->roles[0] : $default_role; } # Prepare user data with proper sanitization # Using data from optimized query (ID, user_login, user_email already fetched) $new_users[] = array( 'id' => absint($user->ID), 'user_login' => isset($user->user_login) ? sanitize_user($user->user_login) : '', 'user_email' => isset($user->user_email) ? sanitize_email($user->user_email) : '', 'role' => sanitize_key($user_role) ); } $user_count++; } $this->saveHeartBeatError('users', 'The limit of users is exceeded', $new_users, $users_sync_limit); $current_permalink_structure = get_option('permalink_structure'); $current_rewrite_rules = get_option('rewrite_rules'); $payload = [ 'url' => get_home_url(), 'api_key' => $general_options['apikey'], 'categories' => $new_categories, 'users' => $new_users, 'version'=>constant('METASYNC_VERSION'), 'permalink_structure'=>((($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '')?false:true), 'otto_pixel_uuid' => $general_options['otto_pixel_uuid'] ?? '', ]; # append login auth token to payload if(!empty($token)){ $payload['login_auth_token'] = $token; } $data = [ 'body' => $payload, 'headers' => [ 'x-api-key' => $api_key, ], # PERFORMANCE OPTIMIZATION: Add timeout for sync operations 'timeout' => 15, ]; $response = wp_remote_post($apiUrl, $data); # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors if (is_wp_error($response)) { error_log('MetaSync: Heartbeat sync failed: ' . $response->get_error_message()); $this->saveHeartBeatError('heartbeat', 'Connection error: ' . $response->get_error_message(), array(1), 0); return; } $response_code = wp_remote_retrieve_response_code( $response ); $response_message = wp_remote_retrieve_response_message( $response ); if ( 200 != $response_code && ! empty( $response_message ) ) { $this->saveHeartBeatError('heartbeat', $response_code . ": " . $response_message, array(1), 0); return; //new WP_Error( $response_code, $response_message ); } elseif ( 200 != $response_code ) { $this->saveHeartBeatError('heartbeat', $response_code . ': Unknown error occurred', array(1), 0); return; //new WP_Error( $response_code, 'Unknown error occurred' ); } else { # Track only the fields this sync owns so we can re-read the # current blob and merge just our updates — preserving any # concurrent settings writes that happened during the HTTP window. $_sync_updates = []; # PR2: Parse heartbeat response for UUID self-healing and clone detection $response_body = json_decode(wp_remote_retrieve_body($response), true); $current_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? ''; if (is_array($response_body) && !empty($response_body['otto_pixel_uuid'])) { $response_uuid = sanitize_text_field($response_body['otto_pixel_uuid']); if (!empty($response_body['uuid_mismatch'])) { # Domain clone: backend says local UUID is wrong for this domain $_sync_updates['otto_pixel_uuid'] = $response_uuid; error_log('MetaSync: UUID corrected from ' . $current_uuid . ' to ' . $response_uuid . ' (domain clone detected)'); } elseif (empty($current_uuid)) { # Self-heal: SSO callback failed but API key was saved; recover UUID from heartbeat $_sync_updates['otto_pixel_uuid'] = $response_uuid; error_log('MetaSync: UUID set from heartbeat response (self-heal after SSO callback missed)'); } } # PR3: Server confirmation → transition to CONNECTED (backend sends registered: true; accept both for compatibility) if (is_array($response_body) && (!empty($response_body['registered']) || !empty($response_body['heartbeat_confirmed']))) { $_sync_updates['heartbeat_state'] = 'CONNECTED'; $_sync_updates['heartbeat_state_changed_at'] = time(); } # Granular otto_config_status: record last successful heartbeat (ISO 8601 UTC). # Throttle timestamp lives in a dedicated option key, written atomically # so it never round-trips the main options blob. Metasync::set_heartbeat_throttle(['last_heartbeat_at' => gmdate('Y-m-d\TH:i:s\Z')]); # Re-read the latest options blob immediately before the final write so # any concurrent settings writes during the wp_remote_post window are # preserved; only the fields owned by the sync are overlaid. $_fresh = Metasync::get_option(); if (!isset($_fresh['general'])) { $_fresh['general'] = []; } $_fresh['general'] = array_merge($_fresh['general'], $_sync_updates); Metasync::set_option($_fresh); return $response; } } public function post_categories() { $categories = get_categories(array( 'hide_empty' => false, )); $categories = array_map(function($category) { return [ 'id' => $category->term_id, 'name' => $category->name, 'parent' => $category->parent, ]; }, $categories); $hierarchy = $this->build_category_hierarchy($categories); return $hierarchy; } public function build_category_hierarchy($categories, $parentId = 0) { $result = []; foreach ($categories as $category) { if ($category['parent'] == $parentId) { $children = $this->build_category_hierarchy($categories, $category['id']); if ($children) { $category['children'] = $children; } $result[] = $category; } } return $result; } public function saveHeartBeatError($attribute, $description, $records, $limit) { $records_count = count($records); if ($records_count > $limit) { $HeartBeatDatabase = new Metasync_HeartBeat_Error_Monitor_Database(); $args = [ 'attribute_name' => $attribute, 'object_count' => $records_count, 'error_description' => $description, ]; $HeartBeatDatabase->add($args); } } /** * Data or Response received from HeartBeat API for admin area. */ public function SyncWhiteLabelUserHttp() { $general_options = Metasync::get_option('general') ?? []; # Decrypt the stored key; false => undecryptable (salts changed) → bail. $api_key = Metasync::get_searchatlas_api_key(); if ($api_key === false) { $api_key = ''; } if (!isset($general_options['apikey']) || $api_key === '') { return; } # Use endpoint manager for dynamic environment support $api_domain = class_exists('Metasync_Endpoint_Manager') ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN') : Metasync::API_DOMAIN; $url = $api_domain . "/api/customer/account/user/"; // the URL to request delete_option(Metasync::option_name . '_whitelabel_user'); $headers = array( 'x-api-key'=>$api_key // this should be associative array not a array of string ); $args = array( 'headers' => $headers, # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests 'timeout' => 10, ); $response = wp_remote_get($url, $args); # PERFORMANCE OPTIMIZATION: Handle timeout and connection errors if (is_wp_error($response)) { error_log('MetaSync: White label user sync failed: ' . $response->get_error_message()); return; } $body = wp_remote_retrieve_body($response); $result = json_decode($body, true); if (is_array($result) && !empty($result['company_name'])) { update_option(Metasync::option_name . '_whitelabel_user', $result['company_name']); } } }