*/ private static $failed_hosts = array(); /** * Without the optional connectivity check, the server counts as offline after this many * different outside hosts failed at the network level in a row. */ const OFFLINE_AFTER_HOSTS = 3; /** * Checks a URL. * * @param string $url Absolute URL. * @return array kind, http_code, error_code, message, final_url, redirect_count, response_ms, method. */ public function check( $url ) { $start = microtime( true ); $result = $this->follow( $url, 'HEAD' ); // Many servers answer HEAD badly. Confirm anything that is not a clean success with GET. if ( $this->needs_get( $result ) ) { $result = $this->follow( $url, 'GET' ); } $result['response_ms'] = (int) round( ( microtime( true ) - $start ) * 1000 ); $result = $this->classify( $result, $url ); /** * Filters the result of a link check. Add-ons can use this to flag soft 404s, * e.g. "product unavailable" pages on affiliate links. * * @param array $result Result. * @param string $url URL that was checked. */ return apply_filters( 'linktender_check_result', $result, $url ); } /** * Checks several URLs at once (one small group of parallel requests). * * The caller keeps the group small and spreads hosts (see LinkTender_Queue::check_due()). * Results are the same as check() for each URL. * * @param string[] $urls URLs keyed by any ID. * @return array[] Results keyed like $urls. */ public function check_many( array $urls ) { if ( count( $urls ) < 2 || ! class_exists( '\WpOrg\Requests\Requests' ) ) { return array_map( array( $this, 'check' ), $urls ); } $start = microtime( true ); $results = $this->follow_many( $urls, 'HEAD' ); $retry = array(); foreach ( $results as $key => $result ) { if ( $this->needs_get( $result ) ) { $retry[ $key ] = $urls[ $key ]; } } if ( $retry ) { $results = array_replace( $results, $this->follow_many( $retry, 'GET' ) ); } $elapsed = (int) round( ( microtime( true ) - $start ) * 1000 ); $out = array(); foreach ( $urls as $key => $url ) { $result = $results[ $key ]; $result['response_ms'] = $elapsed; $result = $this->classify( $result, $url ); /** This filter is documented in includes/class-linktender-checker.php */ $out[ $key ] = apply_filters( 'linktender_check_result', $result, $url ); } return $out; } /** * Whether a HEAD result should be confirmed with GET. * * @param array $result Raw result. * @return bool */ private function needs_get( array $result ) { if ( 'dns' === $result['error_code'] ) { return false; } if ( '' !== $result['error_code'] ) { return true; } return $result['http_code'] >= 400 || $result['http_code'] < 200; } /** * Request arguments. * * @param string $method HTTP method. * @return array */ private function request_args( $method ) { $args = array( 'method' => $method, 'timeout' => (int) LinkTender_Settings::get( 'timeout', 10 ), 'redirection' => 0, 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36', 'headers' => array( 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language' => 'en-US,en;q=0.9', ), 'reject_unsafe_urls' => false, 'decompress' => true, ); if ( 'GET' === $method ) { $args['limit_response_size'] = 65536; } /** * Filters the HTTP request arguments used to check links. * * @param array $args Arguments for wp_remote_request(). * @param string $method HEAD or GET. */ return apply_filters( 'linktender_request_args', $args, $method ); } /** * Requests a URL and follows redirects manually so the chain is known. * * @param string $url URL. * @param string $method Method. * @return array */ private function follow( $url, $method ) { $state = $this->new_state( $url ); while ( ! isset( $state['result'] ) ) { $state = $this->step( $state, wp_remote_request( $state['current'], $this->request_args( $method ) ), $method ); } return $state['result']; } /** * Follows several URLs in parallel, one redirect hop per round. * * @param string[] $urls URLs keyed by ID. * @param string $method Method. * @return array[] Raw results keyed like $urls. */ private function follow_many( array $urls, $method ) { $states = array(); foreach ( $urls as $key => $url ) { $states[ $key ] = $this->new_state( $url ); } $max_rounds = (int) LinkTender_Settings::get( 'max_redirects', 5 ) + 2; for ( $round = 0; $round < $max_rounds; $round++ ) { $pending = array(); foreach ( $states as $key => $state ) { if ( ! isset( $state['result'] ) ) { $pending[ $key ] = $state['current']; } } if ( empty( $pending ) ) { break; } foreach ( $this->send_parallel( $pending, $method ) as $key => $response ) { $states[ $key ] = $this->step( $states[ $key ], $response, $method ); } } $out = array(); foreach ( $states as $key => $state ) { $out[ $key ] = isset( $state['result'] ) ? $state['result'] : $this->error_result( $state, 'too_many_redirects', __( 'Too many redirects', 'linktender' ), $method ); } return $out; } /** * Initial redirect-following state for a URL. * * @param string $url URL. * @return array */ private function new_state( $url ) { return array( 'current' => $url, 'visited' => array( $url => true ), 'hops' => 0, ); } /** * Processes one response: either finishes the state (sets 'result') or moves to the next hop. * * @param array $state State from new_state(). * @param array|WP_Error $response Response. * @param string $method Method. * @return array */ private function step( array $state, $response, $method ) { if ( is_wp_error( $response ) ) { $state['result'] = $this->error_result( $state, $this->error_code( $response ), $response->get_error_message(), $method ); return $state; } $code = (int) wp_remote_retrieve_response_code( $response ); $location = wp_remote_retrieve_header( $response, 'location' ); if ( is_array( $location ) ) { $location = end( $location ); } if ( $code >= 300 && $code < 400 && '' !== (string) $location ) { $next = WP_Http::make_absolute_url( (string) $location, $state['current'] ); ++$state['hops']; $loop = isset( $state['visited'][ $next ] ); if ( $loop || $state['hops'] > (int) LinkTender_Settings::get( 'max_redirects', 5 ) ) { $state['current'] = $next; $state['result'] = $this->error_result( $state, $loop ? 'redirect_loop' : 'too_many_redirects', $loop ? __( 'Redirect loop', 'linktender' ) : __( 'Too many redirects', 'linktender' ), $method, $code ); return $state; } $state['visited'][ $next ] = true; $state['current'] = $next; return $state; } $headers = wp_remote_retrieve_headers( $response ); $state['result'] = array( 'http_code' => $code, 'error_code' => '', 'message' => (string) wp_remote_retrieve_response_message( $response ), 'final_url' => $state['current'], 'redirect_count' => $state['hops'], 'headers' => is_object( $headers ) && method_exists( $headers, 'getAll' ) ? $headers->getAll() : (array) $headers, 'body' => 'GET' === $method ? substr( (string) wp_remote_retrieve_body( $response ), 0, 65536 ) : '', 'method' => $method, ); return $state; } /** * Raw result for a failed request. * * @param array $state State. * @param string $error_code Short code. * @param string $message Message. * @param string $method Method. * @param int $http_code HTTP code, if any. * @return array */ private function error_result( array $state, $error_code, $message, $method, $http_code = 0 ) { return array( 'http_code' => (int) $http_code, 'error_code' => $error_code, 'message' => $message, 'final_url' => $state['current'], 'redirect_count' => $state['hops'], 'headers' => array(), 'body' => '', 'method' => $method, ); } /** * Sends one request per URL at the same time (no redirects followed). * * Uses the Requests library that ships with WordPress and mirrors what wp_remote_request() * does: the same filters, proxy, SSL and the http_api_curl action all apply. * * @param string[] $urls URLs keyed by ID. * @param string $method HEAD or GET. * @return array Response arrays or WP_Error, keyed like $urls. */ private function send_parallel( array $urls, $method ) { $http = _wp_http_get_object(); $out = array(); $requests = array(); foreach ( $urls as $key => $url ) { $args = wp_parse_args( $this->request_args( $method ), array( 'sslverify' => true, 'sslcertificates' => ABSPATH . WPINC . '/certificates/ca-bundle.crt', ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core HTTP filter, applied exactly as wp_remote_request() does. $args = apply_filters( 'http_request_args', $args, $url ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core HTTP filter, applied exactly as wp_remote_request() does. $pre = apply_filters( 'pre_http_request', false, $args, $url ); if ( false !== $pre ) { $out[ $key ] = $pre; continue; } if ( $http->block_request( $url ) ) { $out[ $key ] = new WP_Error( 'http_request_not_executed', __( 'Requests to this host are blocked by the site configuration.', 'linktender' ) ); continue; } $hooks = new WP_HTTP_Requests_Hooks( $url, $args ); // In parallel mode Requests skips "curl.before_send", which is what fires the core // http_api_curl action. Fire it when the handle is added, so plugins that tune cURL still apply. $hooks->register( 'curl.before_multi_add', static function ( &$handle ) use ( $args, $url ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core HTTP action, fired as wp_remote_request() does. do_action_ref_array( 'http_api_curl', array( &$handle, $args, $url ) ); } ); $options = array( 'timeout' => $args['timeout'], 'connect_timeout' => $args['timeout'], 'useragent' => $args['user-agent'], 'blocking' => true, 'follow_redirects' => false, 'hooks' => $hooks, 'verify' => $args['sslverify'] ? $args['sslcertificates'] : false, ); if ( ! $args['sslverify'] ) { $options['verifyname'] = false; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core HTTP filter, applied exactly as wp_remote_request() does. $options['verify'] = apply_filters( 'https_ssl_verify', $options['verify'], $url ); if ( ! empty( $args['limit_response_size'] ) ) { $options['max_bytes'] = (int) $args['limit_response_size']; } $proxy = new WP_HTTP_Proxy(); if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) { $options['proxy'] = new \WpOrg\Requests\Proxy\Http( $proxy->host() . ':' . $proxy->port() ); if ( $proxy->use_authentication() ) { $options['proxy']->use_authentication = true; $options['proxy']->user = $proxy->username(); $options['proxy']->pass = $proxy->password(); } } $requests[ $key ] = array( 'url' => $url, 'type' => $method, 'headers' => isset( $args['headers'] ) ? (array) $args['headers'] : array(), 'data' => null, 'options' => $options, ); } if ( empty( $requests ) ) { return $out; } try { $responses = \WpOrg\Requests\Requests::request_multiple( $requests ); } catch ( \Exception $e ) { // Fall back to one request at a time. foreach ( $requests as $key => $request ) { $out[ $key ] = wp_remote_request( $request['url'], $this->request_args( $method ) ); } return $out; } foreach ( $requests as $key => $request ) { $response = isset( $responses[ $key ] ) ? $responses[ $key ] : null; if ( $response instanceof \WpOrg\Requests\Response ) { $out[ $key ] = ( new WP_HTTP_Requests_Response( $response ) )->to_array(); } else { $message = $response instanceof \Exception ? $response->getMessage() : __( 'Request failed', 'linktender' ); $out[ $key ] = new WP_Error( 'http_request_failed', $message ); } } return $out; } /** * Maps a transport error to a short code. * * @param WP_Error $error Error. * @return string */ private function error_code( WP_Error $error ) { $message = strtolower( $error->get_error_message() ); if ( preg_match( '/resolve host|name or service not known|getaddrinfo|no address associated|curl error 6:/', $message ) ) { return 'dns'; } if ( false !== strpos( $message, 'timed out' ) || false !== strpos( $message, 'curl error 28' ) ) { return 'timeout'; } if ( preg_match( '/refused|curl error 7:|could not connect to server|couldn\'t connect to server/', $message ) ) { return 'refused'; } if ( false !== strpos( $message, 'ssl' ) || false !== strpos( $message, 'certificate' ) ) { return 'ssl'; } return 'connection'; } /** * Whether a response looks like a bot challenge or WAF block page. * * @param array $result Raw result. * @return bool */ public function is_bot_challenge( array $result ) { $headers = array_change_key_case( (array) $result['headers'], CASE_LOWER ); $code = (int) $result['http_code']; if ( isset( $headers['cf-mitigated'] ) ) { return true; } $server = isset( $headers['server'] ) ? strtolower( implode( ' ', (array) $headers['server'] ) ) : ''; if ( in_array( $code, array( 403, 503 ), true ) && preg_match( '/cloudflare|akamai|sucuri|ddos-guard|incapsula|imperva|awselb|cloudfront/', $server ) ) { return true; } if ( isset( $headers['x-amzn-waf-action'] ) || isset( $headers['x-datadome'] ) || isset( $headers['x-sucuri-block'] ) ) { return true; } $body = strtolower( (string) $result['body'] ); if ( '' === $body ) { return false; } // Real challenge pages are small. On a successful answer, only look at small pages so an // article that merely mentions a captcha is not mistaken for one. if ( $code >= 200 && $code < 300 && strlen( $body ) >= 20000 ) { return false; } $markers = array( 'challenge-platform', 'cf-chl-', 'just a moment', 'attention required! | cloudflare', 'ddos-guard', '_incapsula_resource', 'px-captcha', 'captcha-delivery.com', 'sucuri website firewall', 'awswaf', 'please verify you are a human', 'are you a robot', 'checking your browser before accessing', 'pardon our interruption', 'enable javascript and cookies to continue', '/errors/validatecaptcha', 'type the characters you see in this image', 'opfcaptcha', ); /** * Filters body markers that identify bot-protection pages. * * @param string[] $markers Lower-case substrings. */ $markers = apply_filters( 'linktender_challenge_markers', $markers ); foreach ( $markers as $marker ) { if ( false !== strpos( $body, $marker ) ) { return true; } } return false; } /** * Classifies a raw result. * * @param array $result Raw result. * @param string $url Original URL. * @return array */ private function classify( array $result, $url ) { $code = (int) $result['http_code']; $error = (string) $result['error_code']; if ( '' !== $error ) { $kind = self::KIND_TRANSIENT; } elseif ( $this->is_bot_challenge( $result ) ) { $kind = self::KIND_BLOCKED; $result['error_code'] = 'bot_protection'; $result['message'] = __( 'Blocked by bot protection; the link may work fine in a browser.', 'linktender' ); } elseif ( $code >= 200 && $code < 300 ) { $kind = ( $result['redirect_count'] > 0 && $result['final_url'] !== $url ) ? self::KIND_REDIRECT : self::KIND_OK; } elseif ( in_array( $code, array( 404, 410 ), true ) ) { $kind = self::KIND_BROKEN; } elseif ( in_array( $code, array( 401, 403, 407, 429, 999 ), true ) ) { $kind = self::KIND_BLOCKED; $result['error_code'] = 429 === $code ? 'rate_limited' : 'access_denied'; } elseif ( $code >= 500 ) { $kind = self::KIND_TRANSIENT; $result['error_code'] = 'server_error'; } elseif ( $code >= 400 ) { $kind = self::KIND_BLOCKED; $result['error_code'] = 'client_error'; } else { $kind = self::KIND_TRANSIENT; $result['error_code'] = 'unexpected'; } if ( self::KIND_TRANSIENT === $kind && '' !== $error && ! in_array( $error, array( 'redirect_loop', 'too_many_redirects' ), true ) ) { if ( LinkTender_Links::is_internal( $url ) ) { // The site could not reach itself. Many hosts block loopback requests; that proves nothing about the page. $kind = self::KIND_OFFLINE; $result['error_code'] = 'loopback'; $result['message'] = __( 'This server could not connect to its own site (loopback requests are blocked on some hosts). Check the link in a browser.', 'linktender' ); } elseif ( in_array( $error, array( 'dns', 'refused', 'timeout', 'connection' ), true ) && ! self::internet_works( $url ) ) { // This server is offline or firewalled; that says nothing about the link. $kind = self::KIND_OFFLINE; $result['error_code'] = 'offline'; $result['message'] = __( 'This server could not reach the internet, so the link could not be checked.', 'linktender' ); } } elseif ( '' === $error ) { // Any HTTP answer from an outside host proves the connection works. if ( ! LinkTender_Links::is_internal( $url ) ) { self::$net_ok = true; self::$failed_hosts = array(); } } $result['kind'] = $kind; unset( $result['headers'], $result['body'] ); return $result; } /** * Whether this server can reach the internet at all. Guards against reporting every link * as broken when outbound connections or DNS are down. * * Answers from earlier checks are used first. By default no extra request is made: the * server counts as offline once several different outside hosts in a row failed at the * network level without any outside host answering. Only when the administrator turned on * the optional "connectivity check" setting is one HEAD request sent to a well-known address * (wordpress.org by default), cached for 10 minutes. * * @param string $url URL whose check just failed at the network level (optional). * @return bool */ public static function internet_works( $url = '' ) { if ( null !== self::$net_ok ) { return self::$net_ok; } $cached = get_transient( 'linktender_net_ok' ); if ( false !== $cached ) { self::$net_ok = 'yes' === $cached; return self::$net_ok; } if ( ! LinkTender_Settings::get( 'connectivity_check', false ) ) { $host = strtolower( (string) wp_parse_url( (string) $url, PHP_URL_HOST ) ); if ( '' !== $host ) { self::$failed_hosts[ $host ] = true; } if ( count( self::$failed_hosts ) < self::OFFLINE_AFTER_HOSTS ) { return true; } self::$net_ok = false; set_transient( 'linktender_net_ok', 'no', 10 * MINUTE_IN_SECONDS ); return false; } /** * Filters the address used by the optional connectivity check. * * @param string $url URL. */ $probe = wp_remote_head( apply_filters( 'linktender_connectivity_url', 'https://wordpress.org/' ), array( 'timeout' => 8, 'redirection' => 0, ) ); self::$net_ok = ! is_wp_error( $probe ); set_transient( 'linktender_net_ok', self::$net_ok ? 'yes' : 'no', 10 * MINUTE_IN_SECONDS ); return self::$net_ok; } /** * Forgets the cached connectivity answer (used by tests and after a batch). * * @return void */ public static function reset_connectivity() { self::$net_ok = null; self::$failed_hosts = array(); delete_transient( 'linktender_net_ok' ); } }