plugin_name = (string) $plugin_name; $this->stored_listing_write = $stored_listing_write; } /** * Api Request to MLSimport API using CURL * * @param string $method The API method to call. * @param array $values_array The values to pass to the API. * @param string $type The request type (default is 'GET'). * @return mixed The API response or error message. */ public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') { global $mlsimport; // Skip validation for token requests // (the token call is what mints the credential, so it can't require one). if ($method !== 'token') { // Ensure a live JWT before any non-token call; bail out with a message on failure. if (!self::validateAndRefreshToken()) { return 'Token validation failed'; } } // Build the full endpoint URL from the SaaS base + method path. $url = MLSIMPORT_API_URL . $method; // Default headers for the token request (plain text body). $headers = ['Content-Type' => 'text/plain']; // For authenticated calls, swap to JSON + Bearer token headers. if ($method !== 'token') { $token = self::getApiToken(); $headers = [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer '.$token, ]; } // Assemble the wp_remote_* argument array (long timeout for large payloads). $args = [ 'method' => $type, 'headers' => $headers, 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null, 'timeout' => 120, 'redirection' => 10, 'httpversion' => '1.1', 'blocking' => true, 'user-agent' => $_SERVER['HTTP_USER_AGENT'], ]; // Dispatch as GET or POST depending on $type. $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args); // #208 recovery: same rule as globalApiRequestSaas() — one refresh and // one retry when the server rejects the Bearer token mid-flight. if ( 'token' !== $method && ! is_wp_error( $response ) && 401 === intval( $response['response']['code'] ?? 0 ) && self::refreshToken() ) { $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken(); $response = $type === 'GET' ? wp_remote_get( $url, $args ) : wp_remote_post( $url, $args ); } // Transport-level failure: return the WP_Error message string. if (is_wp_error($response)) { return $response->get_error_message(); } else { // Otherwise decode the JSON body and return the array (or a decode-error string). $body = wp_remote_retrieve_body($response); $toReturn = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { return 'JSON decode error: ' . json_last_error_msg(); } return $toReturn; } } /** * Retrieve the API token * * @return string The API token. */ private static function getApiToken() { global $mlsimport; return $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient(); } /** * Api Request to MLSimport API * * @param string $method The API method to call. * @param array $valuesArray The values to pass to the API. * @param string $type The request type (default is 'GET'). * @return array The API response data. */ /** * Fire-and-forget POST to the SaaS API. Refreshes the JWT token (blocking — a * required separate request); returns false without sending if the token is * unavailable. Otherwise issues wp_remote_post() with blocking=false, timeout=0.01 * and returns true. The response is never inspected. * * @param string $method The API method/path to call. * @param array $valuesArray The request body data. * @return bool True if dispatched, false if token unavailable. */ public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool { if ( ! self::validateAndRefreshToken() ) { return false; } $token = self::getApiToken(); wp_remote_post( MLSIMPORT_API_URL . $method, [ 'method' => 'POST', 'timeout' => 0.01, 'blocking' => false, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $valuesArray ), ] ); return true; } /** * Blocking request to the SaaS API returning the decoded response. * * Validates/refreshes the JWT for anything other than the public 'token' * and 'mls' methods, always POSTs the JSON body (regardless of $type), * and normalises errors into a ['success' => false, ...] array. On HTTP 200 * the raw decoded body is returned as-is. * * @param string $method The API method/path to call. * @param array $valuesArray The request body data. * @param string $type The nominal request type (default 'GET'). * @return mixed Decoded response array, or an error descriptor array. */ public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') { global $mlsimport; // Skip validation for token and mls requests if ($method !== 'token' && $method !== 'mls') { // Guarantee a valid token; otherwise return a failure descriptor. if (!self::validateAndRefreshToken()) { return [ 'success' => false, 'error_message' => 'Token validation failed' ]; } } // Full endpoint URL. $url = MLSIMPORT_API_URL . $method; // Attach Bearer auth headers for authenticated methods only. $headers = []; if ($method !== 'token' && $method !== 'mls') { $token = self::getApiToken(); $headers = [ 'Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json', ]; } // Request arguments (note: always dispatched via wp_remote_post below). $args = [ 'method' => $type, 'timeout' => 45, 'redirection' => 5, 'httpversion' => '1.0', 'blocking' => true, 'headers' => $headers, 'cookies' => [], 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null, ]; // Always POST (even for logical GETs) — the SaaS expects a JSON body. $response = wp_remote_post($url, $args); // #208 recovery: a 401 on an authenticated call means the server // rejected the Bearer token even though the stored expiry looked // valid (revoked server-side, clock skew). Refresh once and retry // the same request once; a second 401 falls through to the normal // error path below. Token/mls calls carry no Bearer, so no retry. if ( 'token' !== $method && 'mls' !== $method && ! is_wp_error( $response ) && 401 === intval( $response['response']['code'] ?? 0 ) && self::refreshToken() ) { $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken(); $response = wp_remote_post( $url, $args ); } // Transport error → structured failure with WP error code/message. if (is_wp_error($response)) { return [ 'success' => false, 'error_code' => $response->get_error_code(), 'error_message' => esc_html($response->get_error_message()) ]; } // Extract HTTP status code and raw body. $status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0; $body = wp_remote_retrieve_body($response); // 200 → return the decoded payload untouched. if (200 === $status_code) { $receivedData = json_decode($body, true); return $receivedData; } // Non-200: try to pull a human-readable error out of the JSON body. $error_message = 'Unknown error'; $error_code = $status_code; $decoded_body = json_decode($body, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) { // Preferred shape: { error: { message, code } }. if (isset($decoded_body['error']['message'])) { $error_message = $decoded_body['error']['message']; if (isset($decoded_body['error']['code'])) { $error_code = $decoded_body['error']['code']; } // Fallback shape: { message }. } elseif (isset($decoded_body['message'])) { $error_message = $decoded_body['message']; } } // Return the normalised error descriptor (the exit() below is unreachable). return [ 'success' => false, 'error_code' => $error_code, 'error_message' => esc_html($error_message), ]; exit(); } /** * Check if token is expired and refresh if needed * Call this before any external API request * * @return bool True if token is valid, false if refresh failed */ private static function validateAndRefreshToken() { global $mlsimport; // Get stored expiry timestamp $token_expiry = get_option('mlsimport_token_expiry', 0); $current_time = time(); // Check if token is expired (now at/after the stored expiry). if ($current_time >= $token_expiry) { // Token expired, refresh it $refresh_result = self::refreshToken(); // Propagate refresh failure to the caller. if (!$refresh_result) { return false; } } // Token is present and not past expiry. return true; } /** * Record the SaaS connection-health state (#208). * * Stores array{status, since} in the mlsimport_connection_health option: * 'healthy', 'credentials_invalid' (server rejected the stored account), * 'no_subscription' (password accepted, account not active — #322) or * 'credentials_missing' (nothing configured). Transient failures such * as network timeouts never call this, so a working state is not lost to * a hiccup. Re-recording an unchanged status is skipped so 'since' keeps * pointing at when the state actually began. * * @param string $status New health status keyword. * @return void */ private static function setConnectionHealth( $status ) { $health = get_option( 'mlsimport_connection_health', array() ); if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) { return; } update_option( 'mlsimport_connection_health', array( 'status' => $status, 'since' => time(), ) ); // #208: a state CHANGE is the incident boundary — broken credentials // open the connection incident, a working refresh resolves it. The // alerts module dedups, so this cannot spam the SaaS. if ( 'healthy' === $status ) { if ( function_exists( 'mlsimport_alert_resolve' ) ) { mlsimport_alert_resolve( 'connection:credentials' ); } } elseif ( function_exists( 'mlsimport_alert_open' ) ) { mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) ); } } /** * Request a fresh JWT from the SaaS 'token' endpoint and cache it. * * Reads the stored username/password, POSTs them, and on success stores the * token in a transient plus the expiry timestamp in an option. Bumps the * 'token_failures' telemetry counter on every failure path — WITHOUT a * connection id (#283): the SaaS JWT is account-level, shared by every * connection, so its failures belong to no single MLS and count only in * the global bucket. * * @return bool True on successful refresh, false otherwise. */ private static function refreshToken() { global $mlsimport; // Get credentials for token request $options = get_option('mlsimport_admin_options'); // Pull the SaaS account credentials out of the plugin options. $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : ''; $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : ''; // No credentials configured → cannot refresh. if (empty($username) || empty($password)) { mlsimport_telemetry_bump( 'token_failures' ); self::setConnectionHealth( 'credentials_missing' ); return false; } // #208 single-flight: only one process may refresh at a time. // add_option() is a plain INSERT, so a concurrent process loses the // race and backs off without firing a second token request. A lock // older than 60 seconds belongs to a crashed owner (the token request // itself times out at 45) and is taken over instead. if ( ! add_option( 'mlsimport_token_refresh_lock', time(), '', 'no' ) ) { $lock_held_since = intval( get_option( 'mlsimport_token_refresh_lock', 0 ) ); if ( time() - $lock_held_since < 60 ) { return false; } update_option( 'mlsimport_token_refresh_lock', time() ); } // Prepare token request $url = MLSIMPORT_API_URL . 'token'; $body = wp_json_encode(array( 'username' => $username, 'password' => $password )); $args = array( 'method' => 'POST', 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => $body, 'timeout' => 45 ); // Make token request $response = wp_remote_post($url, $args); // Transport failure → count and abort (lock released for the next try). if (is_wp_error($response)) { mlsimport_telemetry_bump( 'token_failures' ); delete_option( 'mlsimport_token_refresh_lock' ); return false; } // Decode the JSON token response. $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); $code = intval( $response['response']['code'] ?? 0 ); // Reject any response missing success/token/expires. if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) { mlsimport_telemetry_bump( 'token_failures' ); delete_option( 'mlsimport_token_refresh_lock' ); // The server answered and said no → terminal until the user acts. // HTTP 403 means the password was right but the account has no // active subscription (#322); anything else is bad credentials. // A malformed/partial body is a server hiccup instead and leaves // health untouched. if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) { self::setConnectionHealth( 403 === $code ? 'no_subscription' : 'credentials_invalid' ); // Same verdict, remembered for the "not connected" screens. mlsimport_account_status_record( array( 'success' => false, 'error_code' => $code ) ); } return false; } // A working login wipes any remembered failure reason (#322). mlsimport_account_status_record( $data ); // Store new token and expiry //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']); // Cache the token in a transient sized to its remaining lifetime. $expires_in = $data['expires'] - time(); set_transient('mlsimport_saas_token', $data['token'], $expires_in); // Persist the absolute expiry so validateAndRefreshToken() can compare against it. update_option('mlsimport_token_expiry', intval($data['expires'])); // First successful SaaS account connection (lifecycle telemetry). mlsimport_telemetry_set_once( 'account_connected_at', time() ); // Refresh finished — release the single-flight lock. delete_option( 'mlsimport_token_refresh_lock' ); // A minted token proves the account works → back to healthy. self::setConnectionHealth( 'healthy' ); return true; } /** * Write logs for import process * * @param string $logs The log message to write. * @param string $type The type of log. */ private function writeImportLogs($logs, $type) { mlsimport_saas_single_write_import_custom_logs($logs, $type); } /** * Return user option * * @param int $selected The selected user ID. * @return string The HTML option elements for users. */ public function mlsimportSaasThemeImportSelectUser($selected) { $userOptions = ''; // Fetch all users to build a