$raw */ public static function store_pending( $raw ) { update_option( self::PENDING_OPTION, array( 'id' => self::$claimed_id, 'time' => time(), 'data' => $raw, ), false ); } /** * Fuehrt eine Messung aus. * * @param string $url Adresse der eigenen Website (leer = Startseite). * @param bool $hold_lock Die Sperre wird schon vom Aufrufer gehalten (Messreihe). * @return array{ok:true,measurement:array}|array{ok:false,error:array{kind:string,status:int,detail:string}} */ public static function run( $url = '', $hold_lock = false ) { $home = home_url( '/' ); $url = '' === $url ? $home : $url; if ( ! self::is_own_url( $url ) ) { return self::failure( 'invalid_target' ); } if ( $hold_lock ) { return self::attempt( $url, true ); } if ( get_transient( self::LOCK_TRANSIENT ) ) { return self::failure( 'busy' ); } set_transient( self::LOCK_TRANSIENT, 1, self::LOCK_TTL ); try { $result = self::attempt( $url, true ); } finally { delete_transient( self::LOCK_TRANSIENT ); } return $result; } /** * Ein Aufruf; bei einer Weiterleitung auf dieselbe Website wird einmal der Ziel-Adresse gefolgt. * * @return array */ private static function attempt( $url, $may_follow ) { $key = wp_generate_password( 32, false, false ); $id = hash( 'sha256', $key ); self::forget_key(); set_transient( self::KEY_TRANSIENT, $id, self::KEY_TTL ); delete_option( self::PENDING_OPTION ); $target = add_query_arg( array( self::QUERY_ARG => $key, 'wp360_so_r' => wp_generate_password( 8, false, false ), ), $url ); $started = microtime( true ); $response = wp_remote_get( $target, array( 'timeout' => 25, 'redirection' => 0, 'limit_response_size' => 3 * MB_IN_BYTES, 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Filter von WordPress selbst (wie bei dessen eigenen Loopback-Aufrufen). // Accept-Encoding bleibt dem WordPress-HTTP-Client ueberlassen: er dekodiert nur gzip und deflate. 'headers' => array( 'Cache-Control' => 'no-cache', ), ) ); $wall_ms = ( microtime( true ) - $started ) * 1000; // Der Schluessel gilt nur fuer diesen einen Aufruf, ob er eingeloest wurde oder nicht. self::forget_key(); if ( is_wp_error( $response ) ) { $message = $response->get_error_message(); $kind = false !== stripos( $message, 'timed out' ) || false !== stripos( $message, 'timeout' ) ? 'timeout' : 'connection'; return self::failure( $kind, 0, $message ); } $status = (int) wp_remote_retrieve_response_code( $response ); $kind = self::classify_status( $status, $response ); if ( 'redirect' === $kind && $may_follow ) { $location = (string) wp_remote_retrieve_header( $response, 'location' ); $next = '' === $location ? '' : (string) set_url_scheme( $location, null ); if ( '' !== $next && 0 === strpos( $next, '/' ) ) { $next = untrailingslashit( home_url() ) . $next; } if ( '' !== $next && self::is_own_url( $next ) ) { return self::attempt( remove_query_arg( array( self::QUERY_ARG, 'wp360_so_r' ), $next ), false ); } } if ( '' !== $kind ) { return self::failure( $kind, $status ); } $headers = self::headers_of( $response ); $body = (string) wp_remote_retrieve_body( $response ); $raw = self::wait_for_pending( $id ); if ( null === $raw ) { $cache = Wp360_Speed_Optimizer_Doctor_Cache_Detector::detect( $headers ); return self::failure( $cache['hit'] ? 'cache_hit' : 'no_data', $status ); } $html = Wp360_Speed_Optimizer_Doctor_Html_Signals::analyze( $body, home_url() ); $html['asset_headers'] = self::probe_assets( $html['sample_assets'] ); $raw['origin'] = 'frontend'; $raw['url'] = $url; $raw['ttfb_ms'] = self::first_byte_ms( $wall_ms ); $raw['headers'] = $headers; $raw['html'] = $html; return array( 'ok' => true, 'measurement' => Wp360_Speed_Optimizer_Doctor_Measurement::normalize( $raw ), ); } /** * Entfernt den Schluessel aus Datenbank UND Zwischenspeicher. Hat der Server * ihn eingeloest (Zeile geloescht), kennt der Zwischenspeicher dieses * Prozesses ihn noch; delete_option() sieht keine Zeile und raeumt den * Zwischenspeicher dann nicht auf -- das naechste set_transient() im selben * Prozess (z. B. die Messreihe oder ein Cron-Lauf) schriebe ins Leere. */ private static function forget_key() { delete_transient( self::KEY_TRANSIENT ); wp_cache_delete( '_transient_' . self::KEY_TRANSIENT, 'options' ); wp_cache_delete( '_transient_timeout_' . self::KEY_TRANSIENT, 'options' ); wp_cache_delete( 'notoptions', 'options' ); } /** * @param array|WP_Error $response * @return string Fehlerart oder leerer Text, wenn die Antwort brauchbar ist. */ private static function classify_status( $status, $response ) { if ( $status >= 200 && $status < 300 ) { return ''; } if ( $status >= 300 && $status < 400 ) { return 'redirect'; } if ( 401 === $status || 403 === $status ) { return 'access'; } if ( 404 === $status ) { return 'not_found'; } if ( 503 === $status && '' !== (string) wp_remote_retrieve_header( $response, 'retry-after' ) ) { return 'maintenance'; } if ( $status >= 500 ) { return 'server_error'; } return 'unexpected'; } /** * Wartet kurz auf das Ergebnis des Servers (es wird beim Beenden der * gemessenen Anfrage geschrieben und kann der Antwort knapp hinterherlaufen). * * @return array|null */ private static function wait_for_pending( $id ) { $deadline = microtime( true ) + 3.0; do { wp_cache_delete( self::PENDING_OPTION, 'options' ); wp_cache_delete( 'notoptions', 'options' ); $pending = get_option( self::PENDING_OPTION, false ); if ( is_array( $pending ) && isset( $pending['id'], $pending['data'] ) && substr( $id, 0, 16 ) === $pending['id'] && is_array( $pending['data'] ) ) { delete_option( self::PENDING_OPTION ); return $pending['data']; } usleep( 150000 ); } while ( microtime( true ) < $deadline ); return null; } /** * Zeit bis zur Antwort in Millisekunden. Der Selbstaufruf geht an denselben * Server, die Uebertragung des Textes faellt deshalb kaum ins Gewicht; die * Dauer des Aufrufs gilt als Zeit bis zum ersten Byte (leicht darueber). * Curl-Zeiten werden bewusst nicht gelesen: dafuer gaebe es nur direkte * curl_*-Aufrufe, die im Verzeichnis nicht erwuenscht sind. */ private static function first_byte_ms( $wall_ms ) { return round( (float) $wall_ms, 2 ); } /** * @param array|WP_Error $response * @return array */ private static function headers_of( $response ) { $headers = array(); $raw = wp_remote_retrieve_headers( $response ); if ( is_object( $raw ) && method_exists( $raw, 'getAll' ) ) { $raw = $raw->getAll(); } if ( is_array( $raw ) ) { foreach ( $raw as $name => $value ) { $headers[ strtolower( (string) $name ) ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value; } } return $headers; } /** * Fragt die Kopfzeilen einiger Beispiel-Dateien ab (HEAD, kurze Frist). * * @param list $assets * @return list> */ private static function probe_assets( array $assets ) { $result = array(); foreach ( array_slice( $assets, 0, 3 ) as $asset ) { if ( ! self::is_own_url( $asset['url'] ) ) { continue; } $response = wp_remote_head( $asset['url'], array( 'timeout' => 5, 'redirection' => 0, 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Filter von WordPress selbst (wie bei dessen eigenen Loopback-Aufrufen). ) ); if ( is_wp_error( $response ) ) { continue; } $result[] = array( 'url' => $asset['url'], 'type' => $asset['type'], 'status' => (int) wp_remote_retrieve_response_code( $response ), 'cache_control' => (string) wp_remote_retrieve_header( $response, 'cache-control' ), 'expires' => (string) wp_remote_retrieve_header( $response, 'expires' ), 'date' => (string) wp_remote_retrieve_header( $response, 'date' ), ); } return $result; } /** * Nur Adressen der eigenen Website werden aufgerufen (kein Weg zu fremden Servern). */ public static function is_own_url( $url ) { if ( ! is_string( $url ) || '' === $url ) { return false; } $host = wp_parse_url( $url, PHP_URL_HOST ); $own = wp_parse_url( home_url(), PHP_URL_HOST ); if ( ! is_string( $host ) || ! is_string( $own ) ) { return false; } // Keine Zugangsdaten in der Adresse und nur der Port der Website (kein Weg zu anderen Diensten des Servers). if ( null !== wp_parse_url( $url, PHP_URL_USER ) || null !== wp_parse_url( $url, PHP_URL_PASS ) ) { return false; } $port = wp_parse_url( $url, PHP_URL_PORT ); $own_port = wp_parse_url( home_url(), PHP_URL_PORT ); if ( null !== $port && (int) $port !== (int) ( null === $own_port ? ( 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME ) ? 443 : 80 ) : $own_port ) ) { return false; } $strip = static function ( $name ) { $name = strtolower( $name ); return 0 === strpos( $name, 'www.' ) ? substr( $name, 4 ) : $name; }; return $strip( $host ) === $strip( $own ) && in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ); } /** * @return array{ok:false,error:array{kind:string,status:int,detail:string}} */ private static function failure( $kind, $status = 0, $detail = '' ) { // Nach einem Fehlschlag soll kein Rest der Messung liegen bleiben (ausser bei Ablehnung ohne Lauf). if ( ! in_array( $kind, array( 'busy', 'invalid_target' ), true ) ) { delete_option( self::PENDING_OPTION ); } return array( 'ok' => false, 'error' => array( 'kind' => $kind, 'status' => (int) $status, 'detail' => substr( preg_replace( '/[^\x20-\x7E]/', '', (string) $detail ), 0, 200 ), ), ); } /** * Speichert die Messung als letztes Ergebnis ("home" oder "page"). * * @param string $slot * @param array $measurement * @param string $source manual oder scheduled. */ public static function save_result( $slot, array $measurement, $source = 'manual' ) { $results = self::results(); $results[ $slot ] = array( 'time' => time(), 'measurement' => $measurement, ); update_option( self::RESULTS_OPTION, $results, false ); /** * Eine Messung wurde gespeichert (Verlauf, Berichte). * * @param string $slot home oder page. * @param array $measurement Die Messung. * @param string $source manual oder scheduled. */ do_action( 'wp360_so_doctor_measured', $slot, $measurement, $source ); } /** * @return array}> */ public static function results() { $results = get_option( self::RESULTS_OPTION, array() ); return is_array( $results ) ? $results : array(); } }