Keys `ok` (2xx), `status`, `content_type`, `body` * (at most `MAX_BODY` bytes), `headers` (lowercase * names) and `error`. */ public static function fetch( string $url ): array { $url = trim( $url ); if ( '' === $url ) { return self::failure( __( 'No URL to request.', 'acid-aeo' ) ); } $key = self::cache_key( $url ); $cached = Cache::get( $key ); if ( is_array( $cached ) ) { return $cached; } $args = [ 'timeout' => 8, // The audit only ever fetches this site's own URLs, so certificate // verification follows the same filter core uses for its loopbacks: // off by default (a local self-signed certificate is the norm), and // a host that wants it on turns it on for core and for this audit // in one place. // Both arguments on purpose: core has passed the URL as the second one // since WP 5.1, so a site whose callback declares it — the signature // core documents — would take an ArgumentCountError from a one // argument call, which on PHP 8 is a fatal on every audit. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reading core's own loopback filter, not introducing a hook. 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $url ), 'redirection' => 2, 'headers' => [ 'User-Agent' => 'AcidAEO-Audit/' . ACID_AEO_VERSION ], 'cookies' => [], ]; /** * Filters the arguments of an audit loopback request. * * A host that needs a longer timeout, an extra header or basic auth to * answer its own requests can add it here. * * @param array $args Arguments for `wp_remote_get()`. * @param string $url URL being requested. */ $args = apply_filters( 'acid_aeo_loopback_args', $args, $url ); $response = wp_remote_get( $url, is_array( $args ) ? $args : [] ); $result = is_wp_error( $response ) ? self::failure( (string) $response->get_error_message() ) : self::response( $response ); Cache::set( $key, $result, self::TTL ); return $result; } /** * Whether the site can reach itself over HTTP at all. * * Any answer counts, including a 403 or a 404: those come from a web server * that is talking to us. Only a transport error or a server error means the * loopback itself is unusable. * * @return bool True when reachability can be measured. */ public static function is_available(): bool { if ( null !== self::$available ) { return self::$available; } $result = self::fetch( home_url( '/' ) ); self::$available = '' === (string) $result['error'] && (int) $result['status'] > 0 && (int) $result['status'] < 500; return self::$available; } /** * Drops every cached response and asks the site again. * * A fresh audit has to mean fresh: the ten minute cache is there so that * four checks looking at the home page cost one request, not so that an * administrator who just applied a fix is shown the site as it was before * it. Bumping a version rather than deleting keys is what makes this a * single option write instead of a scan for transients whose names are * MD5 hashes of URLs nobody kept a list of. * * @return void */ public static function flush(): void { $version = self::version() + 1; update_option( self::VERSION_OPTION, $version, false ); self::$version = $version; self::$available = null; } /** * Drops the memoized answers without invalidating the cached responses. * * For tests, and for code that has changed nothing but wants the two * request-scoped memos read again. * * @return void */ public static function reset(): void { self::$available = null; self::$version = null; } /** * Current response cache version. * * @return int Version, at least 1. */ public static function version(): int { if ( null === self::$version ) { self::$version = max( 1, (int) get_option( self::VERSION_OPTION, 1 ) ); } return self::$version; } /** * Cache key one URL's response is filed under. * * @param string $url Absolute URL. * @return string Cache key, e.g. `loop_2_9f86d0…`. */ public static function cache_key( string $url ): string { return 'loop_' . self::version() . '_' . md5( $url ); } /** * Turns a successful `wp_remote_get()` result into the audit's shape. * * @param mixed $response Response from `wp_remote_get()`. * @return array Result array. */ private static function response( $response ): array { $status = (int) wp_remote_retrieve_response_code( $response ); $body = (string) wp_remote_retrieve_body( $response ); $headers = self::headers( $response ); return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'content_type' => (string) ( $headers['content-type'] ?? '' ), 'body' => strlen( $body ) > self::MAX_BODY ? substr( $body, 0, self::MAX_BODY ) : $body, 'headers' => $headers, 'error' => '', ]; } /** * A request that never produced a response. * * @param string $error Error message. * @return array Result array. */ private static function failure( string $error ): array { return [ 'ok' => false, 'status' => 0, 'content_type' => '', 'body' => '', 'headers' => [], 'error' => $error, ]; } /** * Response headers as a plain array with lowercase names. * * WordPress hands them over in a case insensitive dictionary object, which * neither survives a transient nor answers `isset()`, so it is flattened * here. A header sent more than once (`Link:`) arrives as an array and is * joined the way the wire format would carry it. * * @param mixed $response Response from `wp_remote_get()`. * @return array Header name => value. */ private static function headers( $response ): array { $raw = wp_remote_retrieve_headers( $response ); if ( is_object( $raw ) && method_exists( $raw, 'getAll' ) ) { $raw = $raw->getAll(); } elseif ( $raw instanceof \Traversable ) { $raw = iterator_to_array( $raw ); } $headers = []; foreach ( is_array( $raw ) ? $raw : [] as $name => $value ) { if ( is_array( $value ) ) { $value = implode( ', ', array_map( 'strval', $value ) ); } $headers[ strtolower( (string) $name ) ] = is_scalar( $value ) ? (string) $value : ''; } return $headers; } }