*/ private array $last = array(); /** * Creates the checker. * * @param Http $http Outbound gateway. * @param string[] $hosts Hosts of the site itself. */ public function __construct( private Http $http, private array $hosts ) {} /** * Checks a URL. * * @param string $url Absolute URL. */ public function check( string $url ): TargetResult { $seen = array(); $current = $url; $started = microtime( true ); for ( $hops = 0; $hops <= self::MAX_HOPS; $hops++ ) { if ( isset( $seen[ $current ] ) ) { return new TargetResult( TargetResult::LOOP, 0, $current, $hops ); } if ( microtime( true ) - $started > self::DEADLINE ) { return new TargetResult( TargetResult::INCONCLUSIVE, 0, $current, $hops, 'Took too long to answer' ); } $seen[ $current ] = true; $internal = Url::is_internal( $current, $this->hosts ); $response = $this->fetch( $current, $internal ); $status = $response['status']; if ( $status >= 300 && $status < 400 && isset( $response['headers']['location'] ) ) { $next = Url::resolve( $response['headers']['location'], $current ); if ( null === $next ) { return new TargetResult( TargetResult::BROKEN, $status, $current, $hops, 'Invalid redirect target' ); } $current = $next; continue; } return new TargetResult( TargetResult::verdict_for( $status, $internal, $response['error'] ), $status, $current, $hops, $response['error'] ); } return new TargetResult( TargetResult::LOOP, 0, $current, self::MAX_HOPS ); } /** * One request: HEAD, then GET when the server does not answer HEAD properly. * * @param string $url URL. * @param bool $internal Whether the URL is on the site. * @return array{status: int, headers: array, body: string, error: string} */ private function fetch( string $url, bool $internal ): array { if ( ! $internal ) { $this->throttle( (string) wp_parse_url( $url, PHP_URL_HOST ) ); } $response = $this->http->request( 'HEAD', $url, array( 'timeout' => self::TIMEOUT ) ); // Some servers reject HEAD or answer it with an empty status. Ask again with a small GET. $head_refused = in_array( $response['status'], array( 403, 405, 501 ), true ); $no_answer = 0 === $response['status'] && '' === $response['error']; if ( $head_refused || $no_answer ) { $args = array( 'timeout' => self::TIMEOUT, 'limit_response_size' => 65536, ); $response = $this->http->request( 'GET', $url, $args ); } return $response; } /** * Waits so one external host gets at most two requests per second. * * @param string $host Host. */ private function throttle( string $host ): void { $now = microtime( true ); if ( isset( $this->last[ $host ] ) ) { $wait = self::HOST_PAUSE - ( $now - $this->last[ $host ] ); if ( $wait > 0 ) { usleep( (int) ( $wait * 1000000 ) ); } } $this->last[ $host ] = microtime( true ); } }