Option values cached for the life of the process. */ private static array $option_cache = []; /** Option holding the peers known to sign their responses, host => time last seen. */ private const SIGNING_PEERS_OPTION = 'shiftora_signing_peers'; /** Option holding the peers known to accept X-Shiftora-Key-Id in place of the token. */ private const KEY_ID_PEERS_OPTION = 'shiftora_key_id_peers'; /** Each peer's own admin-ajax.php URL, as that site advertised it. */ private const AJAX_URLS_OPTION = 'shiftora_peer_ajax_urls'; /** Certificate checking follows the host: verified when public, skipped for local development. */ public const TLS_AUTO = 'auto'; /** Certificates are always verified. */ public const TLS_ALWAYS = 'always'; /** Certificates are never verified; anyone on the network path can read and alter the traffic. */ public const TLS_NEVER = 'never'; /** @var array Secrets of the key ids this request has sent, so a response can be checked. */ private static array $secrets = []; /** @var array Peers already reported as unsigned during this request. */ private static array $unsigned_reported = []; // Structured error codes (4000 range = connection handshake). public const ERR_DNS_FAILURE = 4001; public const ERR_CONNECTION_REFUSED = 4002; public const ERR_TIMEOUT = 4003; public const ERR_SSL = 4004; public const ERR_REMOTE_NOT_FOUND = 4005; public const ERR_REMOTE_ERROR = 4006; public const ERR_BLOCKED_EXTERNAL = 4007; /** * Performs an authenticated GET request to a remote Shiftora endpoint. * * @return array{body: string, code: int, headers: array, effective_url?: string} * @throws RuntimeException On network failure or non-2xx status. */ public function get( string $url, array $headers = [], ?int $timeout = null ): array { return self::request( 'GET', $url, '', $headers, $timeout ); } /** * Performs an authenticated POST request to a remote Shiftora endpoint. * * The `_shiftora_allow_http_fallback` header key enables the HTTPS-to-HTTP port fallback. * * @param string $body Raw JSON body string. * @return array{body: string, code: int, headers: array, effective_url?: string, fallback_from_https?: bool} * @throws RuntimeException On network failure or non-2xx status. */ public function post( string $url, string $body = '', array $headers = [], ?int $timeout = null ): array { $allow_http_fallback = ! empty( $headers['_shiftora_allow_http_fallback'] ); unset( $headers['_shiftora_allow_http_fallback'] ); if ( $allow_http_fallback ) { return self::request_with_https_fallback( 'POST', $url, $body, $headers, $timeout ); } return self::request( 'POST', $url, $body, $headers, $timeout ); } /** * Tries HTTPS, and falls back to HTTP when nothing answers on 443. * * DNS failures are not downgraded, since HTTP cannot fix name resolution. * * @return array{body: string, code: int, headers: array, effective_url?: string, fallback_from_https?: bool} * @throws RuntimeException On failure. */ private static function request_with_https_fallback( string $method, string $url, string $body, array $headers, ?int $timeout = null ): array { $url = self::downgrade_https_when_port_closed( $url ); try { return self::request( $method, $url, $body, $headers, $timeout ); } catch ( RuntimeException $https_error ) { $fallback_url = self::http_fallback_url( $url ); if ( null === $fallback_url || ! self::is_retryable_https_connection_error( $https_error->getMessage() ) ) { throw $https_error; } try { $response = self::request( $method, $fallback_url, $body, $headers, $timeout ); } catch ( RuntimeException $http_error ) { throw new HttpRequestException( sprintf( 'HTTPS connection failed (%s). HTTP fallback also failed (%s).', $https_error->getMessage(), $http_error->getMessage() ), 0, 0, '', null, $http_error ); } $response['effective_url'] = $fallback_url; $response['fallback_from_https'] = true; return $response; } } /** * Returns the http form of an https URL when nothing listens on 443. * * Only for requests that opt into the fallback; transfer traffic keeps its stored scheme. */ private static function downgrade_https_when_port_closed( string $url ): string { if ( 'https' !== wp_parse_url( $url, PHP_URL_SCHEME ) || ! function_exists( 'fsockopen' ) ) { return $url; } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $host || '' === $host ) { return $url; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fsockopen -- Raw socket for a bounded host reachability probe; a failure returns false and is handled below. $fp = @fsockopen( $host, 443, $errno, $errstr, 1 ); if ( false === $fp ) { return self::http_fallback_url( $url ) ?? $url; } fclose( $fp ); return $url; } /** * Quick TCP probe that catches DNS failures and blocked ports in about two * seconds. Not used on the transfer path, where every chunk would open a * second connection; preflight opts in explicitly. * * @throws RuntimeException With a structured error code if the host is unreachable. */ public static function preflight_tcp_check( string $url ): void { if ( ! function_exists( 'fsockopen' ) ) { return; // No raw sockets on this host. } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $host || '' === $host ) { throw new HttpRequestException( __( 'The server cannot determine a valid hostname from the remote URL. Please verify the address.', 'shiftora' ), self::ERR_DNS_FAILURE ); } $port = (int) ( wp_parse_url( $url, PHP_URL_PORT ) ?: ( 'https' === wp_parse_url( $url, PHP_URL_SCHEME ) ? 443 : 80 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fsockopen -- Raw socket for a bounded host reachability probe to the configured port; a failure returns false and is handled below. $fp = @fsockopen( $host, $port, $errno, $errstr, (float) self::TCP_PROBE_TIMEOUT ); if ( ! $fp ) { $error_lower = strtolower( (string) $errstr ); // DNS resolution failure, matched against common OS error strings. if ( str_contains( $error_lower, 'could not resolve' ) || str_contains( $error_lower, 'couldn\'t resolve' ) || str_contains( $error_lower, 'no address associated' ) || str_contains( $error_lower, 'nodename nor servname' ) || str_contains( $error_lower, 'name or service not known' ) ) { throw new HttpRequestException( sprintf( /* translators: %s: the hostname that could not be resolved */ __( 'We could not find: %s. Are you sure this is the correct URL? If you are using LocalWP, Docker, or another local development tool, restarting your site container should fix DNS resolution.', 'shiftora' ), esc_html( $host ) ), self::ERR_DNS_FAILURE ); } // Connection refused. if ( str_contains( $error_lower, 'connection refused' ) ) { throw new HttpRequestException( sprintf( /* translators: 1: hostname:port, 2: port number */ __( 'Could not connect to %1$s. The server refused the connection. The site may be down or a firewall is blocking port %2$d.', 'shiftora' ), esc_html( $host ), $port ), self::ERR_CONNECTION_REFUSED ); } // General socket error. throw new HttpRequestException( sprintf( /* translators: %1$s: hostname, %2$s: error details */ __( 'Could not reach %1$s: %2$s', 'shiftora' ), esc_html( $host ), (string) $errstr ), self::ERR_CONNECTION_REFUSED ); } fclose( $fp ); } /** * Maps a worker deadline (Unix timestamp) to a request timeout. * * Floored at MIN_REQUEST_TIMEOUT: the deadline limits how much new work a * worker starts, and clamping an in-flight request to it causes spurious timeouts. */ public static function timeout_for_deadline( int|float|null $deadline ): int { $config = self::configured_timeout(); if ( null === $deadline ) { return $config; } // microtime() matches how deadlines are computed; time() would floor to a whole second. $remaining = (int) floor( $deadline - microtime( true ) ); if ( $remaining <= 0 ) { return self::MIN_REQUEST_TIMEOUT; } return max( self::MIN_REQUEST_TIMEOUT, min( $config, $remaining ) ); } /** Resolves the configurable request timeout, floored at one second. */ private static function configured_timeout(): int { return max( 1, (int) apply_filters( 'shiftora_request_timeout', self::cached_option( 'shiftora_request_timeout', self::DEFAULT_TIMEOUT ) ) ); } /** * Core request dispatcher. * * @param int|null $timeout Per-request override; null uses the configured value. * @return array{body: string, code: int, headers: array, effective_url?: string} * @throws RuntimeException On WP_Error or non-2xx HTTP status. */ private static function request( string $method, string $url, string $body, array $headers, ?int $timeout = null ): array { // Certificates are verified for a public host and skipped for local development // hosts, which often serve a self-signed one; the setting can force either way. // The filter sees the verdict and only applies for admins, so runtime code cannot // weaken it. $ssl_verify = self::verifies_tls( $url, get_option( 'shiftora_ssl_verify', self::TLS_AUTO ) ); if ( current_user_can( 'manage_options' ) ) { $ssl_verify = (bool) apply_filters( 'shiftora_ssl_verify', $ssl_verify ); } $timeout = $timeout ?? self::configured_timeout(); $started_at = hrtime( true ); $default_headers = [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ]; $args = [ 'method' => strtoupper( $method ), 'timeout' => $timeout, 'connect_timeout' => self::CONNECT_TIMEOUT, 'sslverify' => $ssl_verify, 'headers' => array_merge( $default_headers, $headers ), ]; if ( '' !== $body ) { $args['body'] = $body; } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { $raw_msg = $response->get_error_message(); $raw_lower = strtolower( $raw_msg ); $err_code = self::ERR_REMOTE_ERROR; // Classify into a structured code so callers need not parse prose. if ( str_contains( $raw_lower, 'could not resolve host' ) || str_contains( $raw_lower, 'couldn\'t resolve host' ) ) { $err_code = self::ERR_DNS_FAILURE; } elseif ( str_contains( $raw_lower, 'connection refused' ) || str_contains( $raw_lower, 'failed to connect' ) || str_contains( $raw_lower, 'could not connect to host' ) ) { $err_code = self::ERR_CONNECTION_REFUSED; } elseif ( str_contains( $raw_lower, 'timed out' ) || str_contains( $raw_lower, 'timeout' ) ) { $err_code = self::ERR_TIMEOUT; } elseif ( str_contains( $raw_lower, 'ssl' ) || str_contains( $raw_lower, 'certificate' ) ) { $err_code = self::ERR_SSL; } elseif ( str_contains( $raw_lower, 'block_external' ) || str_contains( $raw_lower, 'wp_http_block_external' ) || str_contains( $raw_lower, 'http_request_not_executed' ) ) { $err_code = self::ERR_BLOCKED_EXTERNAL; } // A blocked external request is a host policy and fails the same way on retry. throw new HttpRequestException( sprintf( 'Shiftora HTTP error: %s', self::humanise_wp_error( $raw_msg ) ), $err_code, 0, is_string( $response->get_error_code() ) ? (string) $response->get_error_code() : '', self::ERR_BLOCKED_EXTERNAL !== $err_code ); } $status_code = wp_remote_retrieve_response_code( $response ); if ( ! is_int( $status_code ) || $status_code < 200 || $status_code >= 300 ) { // Separate variable so the request body stays intact for the admin-ajax replay. $response_body = wp_remote_retrieve_body( $response ); $body_data = json_decode( $response_body, true ); $remote_msg = is_array( $body_data ) && ! empty( $body_data['message'] ) ? ' Remote said: ' . $body_data['message'] : ( ! empty( $response_body ) ? ' Response: ' . substr( $response_body, 0, 200 ) : '' ); // Admin-AJAX fallback for an unreachable REST API (404 from permalinks or a // security plugin, 5xx from a crashed route). $elapsed_s = ( hrtime( true ) - $started_at ) / 1_000_000_000; $remaining = $timeout - (int) ceil( $elapsed_s ); // Only the address the peer advertised is used. A site that never did (an older // build) gets no guess, and the REST error stands. $ajax_url = self::is_rest_url( $url ) ? self::advertised_ajax_url( $url ) : null; if ( null !== $ajax_url && ( 404 === $status_code || $status_code >= 500 ) && $remaining >= 1 ) { $route_query = self::extract_rest_route_and_query( $url ); $proxy_route = $route_query['route']; $proxy_query = $route_query['query']; $ajax_fields = [ 'action' => 'shiftora_proxy', 'route' => $proxy_route, 'method' => $method, 'body' => $body, 'headers' => wp_json_encode( $args['headers'] ?? [] ), ]; // Only sent when present, so receivers that ignore the field are unaffected. if ( '' !== $proxy_query ) { $ajax_fields['query'] = $proxy_query; } // The REST attempt spent part of the budget; the fallback gets the rest. $ajax_args = [ 'method' => 'POST', 'timeout' => $remaining, 'sslverify' => $args['sslverify'] ?? true, 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded' ], 'body' => $ajax_fields, ]; $ajax_response = wp_remote_post( $ajax_url, $ajax_args ); if ( ! is_wp_error( $ajax_response ) ) { $ajax_status = wp_remote_retrieve_response_code( $ajax_response ); $ajax_data = json_decode( (string) wp_remote_retrieve_body( $ajax_response ), true ); // wp_send_json_success/error wraps the payload in { success: bool, data: ... }. if ( is_array( $ajax_data ) && ! empty( $ajax_data['success'] ) ) { $ajax_body = ResponseSignature::envelope_body( $ajax_data['data'] ?? null ); self::verify_response_signature( $url, $args['headers'], (int) $ajax_status, $ajax_body, self::normalise_response_headers( wp_remote_retrieve_headers( $ajax_response ) ) ); return [ 'body' => $ajax_body, 'code' => $ajax_status, 'headers' => [], 'effective_url' => $ajax_url, 'ajax_fallback' => true, ]; } // Admin-ajax answered with { success: false }; prefer its message. if ( is_array( $ajax_data ) && isset( $ajax_data['data']['message'] ) ) { $remote_msg = ' Remote said: ' . $ajax_data['data']['message']; } // A definitive refusal from the replay is the real outcome; reporting the // original 5xx would make the caller retry a request that is always refused. if ( is_int( $ajax_status ) && ! HttpRequestException::status_is_retryable( $ajax_status ) ) { throw new HttpRequestException( sprintf( 'Shiftora HTTP error: remote returned status %d.%s', $ajax_status, $remote_msg ), self::ERR_REMOTE_ERROR, $ajax_status, is_string( $ajax_data['data']['code'] ?? null ) ? (string) $ajax_data['data']['code'] : '', null, null, self::remote_detail( $ajax_data['data']['data'] ?? null ) ); } } // Admin-ajax failed too; fall through to the original error. } $err_code = ( 404 === $status_code ) ? self::ERR_REMOTE_NOT_FOUND : self::ERR_REMOTE_ERROR; throw new HttpRequestException( sprintf( 'Shiftora HTTP error: remote returned status %d.%s', (int) $status_code, $remote_msg ), $err_code, (int) $status_code, is_string( $body_data['code'] ?? null ) ? (string) $body_data['code'] : '', null, null, self::remote_detail( $body_data['data'] ?? null ) ); } $response_body = wp_remote_retrieve_body( $response ); $response_headers = self::normalise_response_headers( wp_remote_retrieve_headers( $response ) ); self::verify_response_signature( $url, $args['headers'], (int) $status_code, $response_body, $response_headers ); return [ 'body' => $response_body, 'code' => (int) $status_code, 'headers' => $response_headers, 'effective_url' => $url, ]; } /** * Records that a site advertised signed responses, so an unsigned one from it is refused. * * Called from the handshake once the capability is read; a signed answer sets the * same flag, so a peer that signed once cannot be downgraded by stripping the header. */ public static function expect_signed_responses( string $url ): void { self::remember_peer( self::SIGNING_PEERS_OPTION, $url ); } /** * Records that a site accepts the key id in place of the token, so requests to it stop * putting the secret on the wire. Read from the handshake or a signed answer, and kept: * a peer that has accepted a key id is never sent the token again. */ public static function expect_key_id( string $url ): void { self::remember_peer( self::KEY_ID_PEERS_OPTION, $url ); } /** * Keeps the admin-ajax.php address a peer advertised in its handshake or ping. * * The address must sit on the peer's own site: same scheme, host and port, one folder * below the peer's base path, named admin-ajax.php, with no userinfo, query, fragment * or dot segment. Where that folder lives is the peer's business, it told us through * admin_url(). What is stored is rebuilt from the checked parts, so the signed fallback * request can never be steered to another site or another file. * * @param string $url The peer's site URL, or any REST URL under it. */ public static function remember_ajax_url( string $url, mixed $advertised ): void { $site = self::site_parts( $url ); if ( null === $site || ! is_string( $advertised ) || str_contains( $advertised, '..' ) ) { return; } $parts = wp_parse_url( $advertised ); if ( ! is_array( $parts ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) || strtolower( (string) ( $parts['scheme'] ?? '' ) ) !== $site['scheme'] || strtolower( (string) ( $parts['host'] ?? '' ) ) !== $site['host'] || (int) ( $parts['port'] ?? 0 ) !== $site['port'] ) { return; } $path = (string) ( $parts['path'] ?? '' ); $tail = substr( $path, strlen( $site['path'] ) ); if ( ! str_starts_with( $path, $site['path'] . '/' ) || 1 !== preg_match( '#^/[^/]+/admin-ajax\.php$#', $tail ) ) { return; } $urls = (array) get_option( self::AJAX_URLS_OPTION, [] ); $urls[ self::site_url( $site ) ] = self::site_url( $site ) . $tail; update_option( self::AJAX_URLS_OPTION, $urls, false ); } /** The admin-ajax.php address the peer behind this URL advertised, or null when it never did. */ public static function advertised_ajax_url( string $url ): ?string { $site = self::site_parts( $url ); $urls = (array) get_option( self::AJAX_URLS_OPTION, [] ); $ajax = null === $site ? null : ( $urls[ self::site_url( $site ) ] ?? null ); return is_string( $ajax ) && '' !== $ajax ? $ajax : null; } /** Drops the address kept for a peer, when its connection is deleted. */ public static function forget_ajax_url( string $url ): void { $site = self::site_parts( $url ); $urls = (array) get_option( self::AJAX_URLS_OPTION, [] ); if ( null !== $site && array_key_exists( self::site_url( $site ), $urls ) ) { unset( $urls[ self::site_url( $site ) ] ); update_option( self::AJAX_URLS_OPTION, $urls, false ); } } /** * A peer's site split into scheme, host, port (0 when default) and base path without a * trailing slash. A REST URL is cut at /wp-json/. * * @return array{scheme: string, host: string, port: int, path: string}|null */ private static function site_parts( string $url ): ?array { $cut = strpos( $url, '/wp-json/' ); $parts = wp_parse_url( false === $cut ? $url : substr( $url, 0, $cut ) ); $scheme = is_array( $parts ) ? strtolower( (string) ( $parts['scheme'] ?? '' ) ) : ''; $host = is_array( $parts ) ? strtolower( (string) ( $parts['host'] ?? '' ) ) : ''; if ( ! in_array( $scheme, [ 'http', 'https' ], true ) || '' === $host ) { return null; } return [ 'scheme' => $scheme, 'host' => $host, 'port' => (int) ( $parts['port'] ?? 0 ), 'path' => rtrim( (string) ( $parts['path'] ?? '' ), '/' ), ]; } /** * The site's normalized base URL, which is also the key a pin is stored under. * * @param array{scheme: string, host: string, port: int, path: string} $site */ private static function site_url( array $site ): string { return $site['scheme'] . '://' . $site['host'] . ( $site['port'] > 0 ? ':' . $site['port'] : '' ) . $site['path']; } public static function peer_signs_responses( string $url ): bool { return self::peer_known( self::SIGNING_PEERS_OPTION, $url ); } public static function peer_accepts_key_id( string $url ): bool { return self::peer_known( self::KEY_ID_PEERS_OPTION, $url ); } /** Keeps the secret behind a key id for this request, to check the response signature with. */ public static function remember_secret( string $key_id, string $secret ): void { self::$secrets[ $key_id ] = $secret; } private static function remember_peer( string $option, string $url ): void { $peer = self::peer_key( $url ); if ( '' === $peer ) { return; } $peers = (array) get_option( $option, [] ); $peers[ $peer ] = time(); update_option( $option, $peers, false ); } private static function peer_known( string $option, string $url ): bool { $peer = self::peer_key( $url ); return '' !== $peer && array_key_exists( $peer, (array) get_option( $option, [] ) ); } /** The stored TLS setting as one of the three modes; anything unrecognised is auto. */ public static function tls_mode( mixed $stored ): string { if ( true === $stored || 1 === $stored || '1' === $stored || self::TLS_ALWAYS === $stored ) { return self::TLS_ALWAYS; } return self::TLS_NEVER === $stored ? self::TLS_NEVER : self::TLS_AUTO; } /** Whether a request to this URL checks the remote certificate under the stored setting. */ public static function verifies_tls( string $url, mixed $stored ): bool { return match ( self::tls_mode( $stored ) ) { self::TLS_ALWAYS => true, self::TLS_NEVER => false, default => ! self::is_local_host( (string) wp_parse_url( $url, PHP_URL_HOST ) ), }; } /** * Whether a host is a local development address: localhost, .local, .test, .localhost * and .lan names, a name with no dot (a container or LAN machine), or an IP in a * private, loopback or link-local range. None of these can hold a public certificate. */ public static function is_local_host( string $host ): bool { $host = rtrim( strtolower( trim( $host, "[] \t\n\r." ) ), '.' ); if ( '' === $host ) { return false; } if ( false !== filter_var( $host, FILTER_VALIDATE_IP ) ) { return false === filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } if ( 'localhost' === $host || ! str_contains( $host, '.' ) ) { return true; } foreach ( [ '.local', '.test', '.localhost', '.lan' ] as $suffix ) { if ( str_ends_with( $host, $suffix ) ) { return true; } } return false; } /** * Checks the signature on a response to a token-authenticated request. * * A peer on a build that predates signing sends none and is still accepted until * it has shown it can sign (a header seen, or the capability advertised). * * @param array $request_headers Headers this site sent. * @param array $response_headers Headers the peer returned. * * @throws HttpRequestException When the signature is wrong, stale, or missing from a peer that signs. */ private static function verify_response_signature( string $url, array $request_headers, int $status, string $body, array $response_headers ): void { $secret = self::header_value( $request_headers, 'X-Shiftora-Token' ); if ( '' === $secret ) { // The key id travels instead of the token, and the secret stays in memory. $secret = self::$secrets[ self::header_value( $request_headers, 'X-Shiftora-Key-Id' ) ] ?? ''; } $request_signature = self::header_value( $request_headers, 'X-Shiftora-Signature' ); if ( '' === $secret || '' === $request_signature || ! ResponseSignature::covers( $url ) ) { return; } $signature = self::header_value( $response_headers, ResponseSignature::SIGNATURE_HEADER ); $peer = self::peer_key( $url ); if ( '' === $signature ) { if ( array_key_exists( $peer, (array) get_option( self::SIGNING_PEERS_OPTION, [] ) ) ) { throw new HttpRequestException( 'Shiftora HTTP error: the other site stopped signing its responses, so this one was discarded.', self::ERR_REMOTE_ERROR, $status, 'shiftora_response_unsigned', false ); } if ( ! isset( self::$unsigned_reported[ $peer ] ) ) { self::$unsigned_reported[ $peer ] = true; ServerLog::write( "[Shiftora] {$peer} does not sign its responses (an earlier build); update it so a pull cannot be altered in transit." ); } return; } $timestamp = self::header_value( $response_headers, ResponseSignature::TIMESTAMP_HEADER ); if ( ! ResponseSignature::verify( $secret, $request_signature, $status, $body, $timestamp, $signature ) ) { throw new HttpRequestException( 'Shiftora HTTP error: the response from the other site failed its signature check and was discarded.', self::ERR_REMOTE_ERROR, $status, 'shiftora_response_forged', false ); } self::expect_signed_responses( $url ); // A build that signs also accepts the key id; they shipped together. self::expect_key_id( $url ); } /** One header's value, matched without regard to case; '' when absent. */ private static function header_value( array $headers, string $name ): string { foreach ( $headers as $key => $value ) { if ( is_string( $key ) && 0 === strcasecmp( $key, $name ) ) { return is_array( $value ) ? (string) reset( $value ) : (string) $value; } } return ''; } /** host[:port] of a URL, lower case; '' when it has none. */ private static function peer_key( string $url ): string { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); $port = wp_parse_url( $url, PHP_URL_PORT ); return '' === $host ? '' : $host . ( $port ? ':' . $port : '' ); } /** * The detail a remote site attached to its refusal. * * Only `data` is kept, and only when it holds an array under the `shiftora` * key; the rest of a public error body is whatever the remote put there. * * @return array */ private static function remote_detail( mixed $detail ): array { if ( ! is_array( $detail ) || ! isset( $detail['shiftora'] ) || ! is_array( $detail['shiftora'] ) ) { return []; } return $detail; } /** Converts WordPress response headers into a plain array across WP versions. */ private static function normalise_response_headers( mixed $headers ): array { if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) { return (array) $headers->getAll(); } return is_array( $headers ) ? $headers : []; } /** Returns the HTTP equivalent of an HTTPS URL, or null when not applicable. */ private static function http_fallback_url( string $url ): ?string { $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( 'https' !== $scheme ) { return null; } return preg_replace( '#^https://#i', 'http://', $url, 1 ) ?: null; } /** Whether an HTTPS failure is worth retrying over HTTP. */ private static function is_retryable_https_connection_error( string $message ): bool { $lower = strtolower( $message ); if ( str_contains( $lower, 'could not resolve host' ) || str_contains( $lower, 'couldn\'t resolve host' ) ) { return false; } return str_contains( $lower, 'port 443' ) || str_contains( $lower, 'connection refused' ) || str_contains( $lower, 'could not connect to host' ) || str_contains( $lower, 'failed to connect' ); } /** Translates common WP HTTP / cURL errors into actionable messages. */ private static function humanise_wp_error( string $raw_msg ): string { $raw_lower = strtolower( $raw_msg ); // cURL error 6: DNS resolution failure. if ( str_contains( $raw_lower, 'could not resolve host' ) || str_contains( $raw_lower, 'couldn\'t resolve host' ) ) { return $raw_msg . '. ' . __( 'This usually means the local server cannot resolve the domain name. If you are using LocalWP, Docker, or another local development tool, its container may not have internet DNS access. Try: 1) Restarting the local site, 2) Checking the tool\'s network settings, 3) Ensuring the site can reach the internet.', 'shiftora' ); } // cURL error 7: connection refused. if ( str_contains( $raw_lower, 'could not connect to host' ) || str_contains( $raw_lower, 'connection refused' ) || str_contains( $raw_lower, 'failed to connect' ) ) { return $raw_msg . '. ' . __( 'The remote server refused the connection. The site may be down, the port may be blocked by a firewall, or the URL may be incorrect.', 'shiftora' ); } // cURL error 28: timeout. if ( str_contains( $raw_lower, 'timed out' ) || str_contains( $raw_lower, 'timeout' ) ) { return $raw_msg . '. ' . __( 'The connection timed out. The remote site may be slow or unreachable. You can increase the request timeout in Shiftora settings.', 'shiftora' ); } // cURL 35 / 60: with verification off by default this is a protocol or cipher mismatch. if ( str_contains( $raw_lower, 'ssl' ) || str_contains( $raw_lower, 'certificate' ) ) { return $raw_msg . '. ' . __( 'There was an SSL/TLS handshake error. The remote site may be using a TLS version or cipher this server does not support. Check that the remote site\'s TLS configuration and this server\'s OpenSSL are compatible.', 'shiftora' ); } // WP_HTTP_BLOCK_EXTERNAL. if ( str_contains( $raw_lower, 'block_external' ) || str_contains( $raw_lower, 'wp_http_block_external' ) || str_contains( $raw_lower, 'blocked requests through http' ) || str_contains( $raw_lower, 'http_request_not_executed' ) ) { return $raw_msg . '. ' . __( 'WordPress is configured to block external HTTP requests (WP_HTTP_BLOCK_EXTERNAL). Add the remote host to WP_ACCESSIBLE_HOSTS or disable WP_HTTP_BLOCK_EXTERNAL in wp-config.php.', 'shiftora' ); } return $raw_msg; } /** Whether a URL targets a WordPress REST API endpoint. */ private static function is_rest_url( string $url ): bool { return str_contains( $url, '/wp-json/' ); } /** * Splits a full REST URL into its route path and raw query string. * * The query is kept byte-for-byte so a signature over "route?query" can be * rebuilt on the receiver. * * @return array{route: string, query: string} */ public static function extract_rest_route_and_query( string $url ): array { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); $pos = strpos( $path, '/wp-json/' ); if ( false === $pos ) { return [ 'route' => '', 'query' => $query ]; } return [ 'route' => (string) substr( $path, $pos + strlen( '/wp-json' ) ), 'query' => $query, ]; } /** * Reads an option with a static per-process cache; background jobs often start * with a cold object cache. */ private static function cached_option( string $key, mixed $default = false ): mixed { if ( ! array_key_exists( $key, self::$option_cache ) ) { self::$option_cache[ $key ] = get_option( $key, $default ); } return self::$option_cache[ $key ]; } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fclose }